pi-wsearch 0.1.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/LICENSE +21 -0
- package/README.md +147 -0
- package/index.ts +8 -0
- package/justfile +31 -0
- package/package.json +63 -0
- package/src/cache.ts +91 -0
- package/src/config.ts +263 -0
- package/src/constants.ts +6 -0
- package/src/engines.ts +384 -0
- package/src/fuse.ts +240 -0
- package/src/keys.ts +104 -0
- package/src/scrape.ts +490 -0
- package/src/tools.ts +527 -0
- package/src/transport.ts +389 -0
- package/src/urls.ts +68 -0
package/src/engines.ts
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { getConfig, sanitizeError, type ProviderKey, type WebConfig } from './config';
|
|
2
|
+
import { ABORT_ERROR, DEFAULT_MAX_RESULTS } from './constants';
|
|
3
|
+
|
|
4
|
+
export interface SearchHit {
|
|
5
|
+
title: string;
|
|
6
|
+
url: string;
|
|
7
|
+
snippet: string;
|
|
8
|
+
engine: string;
|
|
9
|
+
date?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface EngineOutcome {
|
|
13
|
+
engine: string;
|
|
14
|
+
hits: SearchHit[];
|
|
15
|
+
error?: string;
|
|
16
|
+
latencyMs: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type EngineName = 'serper' | 'tavily' | 'exa' | 'brave' | 'jina' | 'kagi' | 'you' | 'firecrawl' | 'tinyfish';
|
|
20
|
+
|
|
21
|
+
export interface SearchOptions {
|
|
22
|
+
query: string;
|
|
23
|
+
maxResults?: number;
|
|
24
|
+
engines?: EngineName[];
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface EngineDef {
|
|
29
|
+
id: EngineName;
|
|
30
|
+
key: ProviderKey;
|
|
31
|
+
available(cfg: WebConfig): boolean;
|
|
32
|
+
search(key: string, opts: SearchOptions, signal?: AbortSignal): Promise<EngineOutcome>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const jsonHeaders = (extra: Record<string, string> = {}) => ({
|
|
36
|
+
'Content-Type': 'application/json',
|
|
37
|
+
...extra,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export async function postJson<T>(
|
|
41
|
+
url: string,
|
|
42
|
+
body: unknown,
|
|
43
|
+
headers: Record<string, string>,
|
|
44
|
+
signal?: AbortSignal,
|
|
45
|
+
): Promise<T> {
|
|
46
|
+
return request<T>(url, { method: 'POST', headers, body: JSON.stringify(body) }, signal);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function getJson<T>(url: string, headers: Record<string, string>, signal?: AbortSignal): Promise<T> {
|
|
50
|
+
return request<T>(url, { method: 'GET', headers }, signal);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function request<T>(
|
|
54
|
+
url: string,
|
|
55
|
+
init: { method: string; headers: Record<string, string>; body?: string },
|
|
56
|
+
signal?: AbortSignal,
|
|
57
|
+
): Promise<T> {
|
|
58
|
+
const ctrl = new AbortController();
|
|
59
|
+
const t = setTimeout(() => ctrl.abort(new Error('engine timeout')), 12_000);
|
|
60
|
+
const onAbort = () => ctrl.abort(signal?.reason ?? new Error(ABORT_ERROR));
|
|
61
|
+
// An already-aborted signal never fires abort listeners: pre-abort so the
|
|
62
|
+
// fetch rejects immediately instead of running its full timeout.
|
|
63
|
+
if (signal?.aborted) ctrl.abort(signal?.reason ?? new Error(ABORT_ERROR));
|
|
64
|
+
else signal?.addEventListener('abort', onAbort, { once: true });
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch(url, { method: init.method, headers: init.headers, body: init.body, signal: ctrl.signal });
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
const txt = await res.text().catch(() => '');
|
|
69
|
+
throw new Error(`HTTP ${res.status}: ${txt.slice(0, 160)}`);
|
|
70
|
+
}
|
|
71
|
+
return (await res.json()) as T;
|
|
72
|
+
} finally {
|
|
73
|
+
clearTimeout(t);
|
|
74
|
+
signal?.removeEventListener('abort', onAbort);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function serper(key: string, opts: SearchOptions, signal?: AbortSignal): Promise<EngineOutcome> {
|
|
79
|
+
const t0 = Date.now();
|
|
80
|
+
try {
|
|
81
|
+
const body: Record<string, unknown> = {
|
|
82
|
+
q: opts.query,
|
|
83
|
+
num: opts.maxResults ?? DEFAULT_MAX_RESULTS,
|
|
84
|
+
gl: 'us',
|
|
85
|
+
};
|
|
86
|
+
const d = await postJson<{
|
|
87
|
+
organic?: { title?: string; link?: string; snippet?: string; date?: string }[];
|
|
88
|
+
}>('https://google.serper.dev/search', body, jsonHeaders({ 'X-API-KEY': key }), signal);
|
|
89
|
+
const hits: SearchHit[] = (d.organic ?? [])
|
|
90
|
+
.filter((r) => r?.link)
|
|
91
|
+
.map((r) => ({
|
|
92
|
+
title: r.title ?? r.link!,
|
|
93
|
+
url: r.link!,
|
|
94
|
+
snippet: r.snippet ?? '',
|
|
95
|
+
engine: 'serper',
|
|
96
|
+
date: r.date,
|
|
97
|
+
}));
|
|
98
|
+
return { engine: 'serper', hits, latencyMs: Date.now() - t0 };
|
|
99
|
+
} catch (e) {
|
|
100
|
+
return { engine: 'serper', hits: [], error: sanitizeError(e), latencyMs: Date.now() - t0 };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function tavily(key: string, opts: SearchOptions, signal?: AbortSignal): Promise<EngineOutcome> {
|
|
105
|
+
const t0 = Date.now();
|
|
106
|
+
try {
|
|
107
|
+
const body: Record<string, unknown> = {
|
|
108
|
+
query: opts.query,
|
|
109
|
+
max_results: opts.maxResults ?? DEFAULT_MAX_RESULTS,
|
|
110
|
+
search_depth: 'basic',
|
|
111
|
+
topic: 'general',
|
|
112
|
+
};
|
|
113
|
+
// Docs: auth = Authorization: Bearer header
|
|
114
|
+
const d = await postJson<{
|
|
115
|
+
results?: { title?: string; url?: string; content?: string; published_date?: string }[];
|
|
116
|
+
}>('https://api.tavily.com/search', body, jsonHeaders({ Authorization: `Bearer ${key}` }), signal);
|
|
117
|
+
const hits: SearchHit[] = (d.results ?? [])
|
|
118
|
+
.filter((r) => r?.url)
|
|
119
|
+
.map((r) => ({
|
|
120
|
+
title: r.title ?? r.url!,
|
|
121
|
+
url: r.url!,
|
|
122
|
+
snippet: r.content ?? '',
|
|
123
|
+
engine: 'tavily',
|
|
124
|
+
date: r.published_date,
|
|
125
|
+
}));
|
|
126
|
+
return { engine: 'tavily', hits, latencyMs: Date.now() - t0 };
|
|
127
|
+
} catch (e) {
|
|
128
|
+
return { engine: 'tavily', hits: [], error: sanitizeError(e), latencyMs: Date.now() - t0 };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function exa(key: string, opts: SearchOptions, signal?: AbortSignal): Promise<EngineOutcome> {
|
|
133
|
+
const t0 = Date.now();
|
|
134
|
+
try {
|
|
135
|
+
const body: Record<string, unknown> = {
|
|
136
|
+
query: opts.query,
|
|
137
|
+
numResults: opts.maxResults ?? DEFAULT_MAX_RESULTS,
|
|
138
|
+
contents: { highlights: true },
|
|
139
|
+
};
|
|
140
|
+
const d = await postJson<{
|
|
141
|
+
results?: { title?: string; url?: string; highlights?: string[]; publishedDate?: string }[];
|
|
142
|
+
}>('https://api.exa.ai/search', body, jsonHeaders({ 'x-api-key': key }), signal);
|
|
143
|
+
const hits: SearchHit[] = (d.results ?? [])
|
|
144
|
+
.filter((r) => r?.url)
|
|
145
|
+
.map((r) => ({
|
|
146
|
+
title: r.title ?? r.url!,
|
|
147
|
+
url: r.url!,
|
|
148
|
+
snippet: (r.highlights ?? []).join(' '),
|
|
149
|
+
engine: 'exa',
|
|
150
|
+
date: r.publishedDate,
|
|
151
|
+
}));
|
|
152
|
+
return { engine: 'exa', hits, latencyMs: Date.now() - t0 };
|
|
153
|
+
} catch (e) {
|
|
154
|
+
return { engine: 'exa', hits: [], error: sanitizeError(e), latencyMs: Date.now() - t0 };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function brave(key: string, opts: SearchOptions, signal?: AbortSignal): Promise<EngineOutcome> {
|
|
159
|
+
const t0 = Date.now();
|
|
160
|
+
try {
|
|
161
|
+
const url = new URL('https://api.search.brave.com/res/v1/web/search');
|
|
162
|
+
url.searchParams.set('q', opts.query);
|
|
163
|
+
url.searchParams.set('count', String(opts.maxResults ?? DEFAULT_MAX_RESULTS));
|
|
164
|
+
url.searchParams.set('extra_snippets', 'true');
|
|
165
|
+
url.searchParams.set('text_decorations', 'false');
|
|
166
|
+
url.searchParams.set('safesearch', 'moderate');
|
|
167
|
+
const d = await getJson<{
|
|
168
|
+
web?: {
|
|
169
|
+
results?: { title?: string; url?: string; description?: string; extra_snippets?: string[]; age?: string }[];
|
|
170
|
+
};
|
|
171
|
+
}>(url.toString(), { Accept: 'application/json', 'X-Subscription-Token': key }, signal);
|
|
172
|
+
const hits: SearchHit[] = (d.web?.results ?? [])
|
|
173
|
+
.filter((r) => r?.url)
|
|
174
|
+
.map((r) => ({
|
|
175
|
+
title: r.title ?? r.url!,
|
|
176
|
+
url: r.url!,
|
|
177
|
+
snippet: [r.description, ...(r.extra_snippets ?? [])].filter(Boolean).join('\n'),
|
|
178
|
+
engine: 'brave',
|
|
179
|
+
date: r.age,
|
|
180
|
+
}));
|
|
181
|
+
return { engine: 'brave', hits, latencyMs: Date.now() - t0 };
|
|
182
|
+
} catch (e) {
|
|
183
|
+
return { engine: 'brave', hits: [], error: sanitizeError(e), latencyMs: Date.now() - t0 };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function jina(key: string, opts: SearchOptions, signal?: AbortSignal): Promise<EngineOutcome> {
|
|
188
|
+
const t0 = Date.now();
|
|
189
|
+
try {
|
|
190
|
+
// documented form is s.jina.ai/?q=; search requires a key (no key = blocked)
|
|
191
|
+
const url = new URL('https://s.jina.ai/');
|
|
192
|
+
url.searchParams.set('q', opts.query);
|
|
193
|
+
url.searchParams.set('count', String(opts.maxResults ?? DEFAULT_MAX_RESULTS));
|
|
194
|
+
const headers: Record<string, string> = {
|
|
195
|
+
Accept: 'application/json',
|
|
196
|
+
'X-Respond-With': 'no-content',
|
|
197
|
+
'X-Retain-Images': 'none',
|
|
198
|
+
};
|
|
199
|
+
headers.Authorization = `Bearer ${key}`;
|
|
200
|
+
const d = await getJson<{
|
|
201
|
+
data?: { title?: string | null; url?: string | null; description?: string | null; content?: string | null }[];
|
|
202
|
+
}>(url.toString(), headers, signal);
|
|
203
|
+
const hits: SearchHit[] = (d.data ?? [])
|
|
204
|
+
.filter((r) => r?.url)
|
|
205
|
+
.map((r) => ({
|
|
206
|
+
title: r.title ?? r.url!,
|
|
207
|
+
url: r.url!,
|
|
208
|
+
snippet: r.description?.trim() || r.content?.trim() || '',
|
|
209
|
+
engine: 'jina',
|
|
210
|
+
}));
|
|
211
|
+
return { engine: 'jina', hits, latencyMs: Date.now() - t0 };
|
|
212
|
+
} catch (e) {
|
|
213
|
+
return { engine: 'jina', hits: [], error: sanitizeError(e), latencyMs: Date.now() - t0 };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function kagi(key: string, opts: SearchOptions, signal?: AbortSignal): Promise<EngineOutcome> {
|
|
218
|
+
const t0 = Date.now();
|
|
219
|
+
try {
|
|
220
|
+
const url = new URL('https://kagi.com/api/v1/search');
|
|
221
|
+
url.searchParams.set('q', opts.query);
|
|
222
|
+
url.searchParams.set('limit', String(opts.maxResults ?? DEFAULT_MAX_RESULTS));
|
|
223
|
+
const d = await getJson<{
|
|
224
|
+
data?: {
|
|
225
|
+
search?: {
|
|
226
|
+
url?: string;
|
|
227
|
+
link?: string;
|
|
228
|
+
title?: string;
|
|
229
|
+
t?: string;
|
|
230
|
+
snippet?: string;
|
|
231
|
+
s?: string;
|
|
232
|
+
published?: string;
|
|
233
|
+
}[];
|
|
234
|
+
};
|
|
235
|
+
}>(url.toString(), { Accept: 'application/json', Authorization: `Bot ${key}` }, signal);
|
|
236
|
+
const hits: SearchHit[] = (d.data?.search ?? [])
|
|
237
|
+
.filter((r) => r?.url || r?.link)
|
|
238
|
+
.map((r) => ({
|
|
239
|
+
title: r.title ?? r.t ?? r.url ?? r.link!,
|
|
240
|
+
url: r.url ?? r.link!,
|
|
241
|
+
snippet: r.snippet ?? r.s ?? '',
|
|
242
|
+
engine: 'kagi',
|
|
243
|
+
date: r.published,
|
|
244
|
+
}));
|
|
245
|
+
return { engine: 'kagi', hits, latencyMs: Date.now() - t0 };
|
|
246
|
+
} catch (e) {
|
|
247
|
+
return { engine: 'kagi', hits: [], error: sanitizeError(e), latencyMs: Date.now() - t0 };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function you(key: string, opts: SearchOptions, signal?: AbortSignal): Promise<EngineOutcome> {
|
|
252
|
+
const t0 = Date.now();
|
|
253
|
+
try {
|
|
254
|
+
const body: Record<string, unknown> = {
|
|
255
|
+
query: opts.query,
|
|
256
|
+
count: opts.maxResults ?? DEFAULT_MAX_RESULTS,
|
|
257
|
+
safesearch: 'moderate',
|
|
258
|
+
};
|
|
259
|
+
const d = await postJson<{
|
|
260
|
+
results?: {
|
|
261
|
+
web?: { url?: string; title?: string; description?: string; snippets?: string[]; page_age?: string }[];
|
|
262
|
+
news?: { url?: string; title?: string; description?: string; page_age?: string }[];
|
|
263
|
+
};
|
|
264
|
+
}>('https://ydc-index.io/v1/search', body, jsonHeaders({ 'X-API-Key': key }), signal);
|
|
265
|
+
const rows = [
|
|
266
|
+
...(d.results?.web ?? []).map((w) => ({
|
|
267
|
+
title: w.title ?? w.url ?? '',
|
|
268
|
+
url: w.url ?? '',
|
|
269
|
+
snippet: w.snippets?.join('\n') || w.description || '',
|
|
270
|
+
date: w.page_age,
|
|
271
|
+
})),
|
|
272
|
+
...(d.results?.news ?? []).map((n) => ({
|
|
273
|
+
title: n.title ?? n.url ?? '',
|
|
274
|
+
url: n.url ?? '',
|
|
275
|
+
snippet: n.description || '',
|
|
276
|
+
date: n.page_age,
|
|
277
|
+
})),
|
|
278
|
+
].filter((r) => r.url);
|
|
279
|
+
const hits: SearchHit[] = rows.map((r) => ({ ...r, engine: 'you' }));
|
|
280
|
+
return { engine: 'you', hits, latencyMs: Date.now() - t0 };
|
|
281
|
+
} catch (e) {
|
|
282
|
+
return { engine: 'you', hits: [], error: sanitizeError(e), latencyMs: Date.now() - t0 };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function firecrawl(key: string, opts: SearchOptions, signal?: AbortSignal): Promise<EngineOutcome> {
|
|
287
|
+
const t0 = Date.now();
|
|
288
|
+
try {
|
|
289
|
+
const body: Record<string, unknown> = {
|
|
290
|
+
query: opts.query,
|
|
291
|
+
limit: opts.maxResults ?? DEFAULT_MAX_RESULTS,
|
|
292
|
+
sources: ['web'],
|
|
293
|
+
};
|
|
294
|
+
const headers: Record<string, string> = {
|
|
295
|
+
Accept: 'application/json',
|
|
296
|
+
'Content-Type': 'application/json',
|
|
297
|
+
Authorization: `Bearer ${key}`,
|
|
298
|
+
};
|
|
299
|
+
type FcHit = { url?: string; title?: string; description?: string; snippet?: string; markdown?: string };
|
|
300
|
+
const d = await request<{
|
|
301
|
+
success?: boolean;
|
|
302
|
+
data?: { web?: FcHit[] } | FcHit[];
|
|
303
|
+
results?: FcHit[];
|
|
304
|
+
}>('https://api.firecrawl.dev/v2/search', { method: 'POST', headers, body: JSON.stringify(body) }, signal);
|
|
305
|
+
const web: FcHit[] = Array.isArray(d.data) ? d.data : (d.data?.web ?? d.results ?? []);
|
|
306
|
+
const hits: SearchHit[] = web
|
|
307
|
+
.filter((r) => r?.url)
|
|
308
|
+
.map((r) => ({
|
|
309
|
+
title: r.title ?? r.url!,
|
|
310
|
+
url: r.url!,
|
|
311
|
+
snippet: r.description ?? r.snippet ?? r.markdown ?? '',
|
|
312
|
+
engine: 'firecrawl',
|
|
313
|
+
}));
|
|
314
|
+
return { engine: 'firecrawl', hits, latencyMs: Date.now() - t0 };
|
|
315
|
+
} catch (e) {
|
|
316
|
+
return { engine: 'firecrawl', hits: [], error: sanitizeError(e), latencyMs: Date.now() - t0 };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function tinyfish(key: string, opts: SearchOptions, signal?: AbortSignal): Promise<EngineOutcome> {
|
|
321
|
+
const t0 = Date.now();
|
|
322
|
+
try {
|
|
323
|
+
const url = new URL('https://api.search.tinyfish.ai');
|
|
324
|
+
url.searchParams.set('query', opts.query);
|
|
325
|
+
if (opts.maxResults) url.searchParams.set('num_results', String(opts.maxResults));
|
|
326
|
+
const d = await getJson<{ results?: { url?: string; title?: string; snippet?: string; position?: number }[] }>(
|
|
327
|
+
url.toString(),
|
|
328
|
+
{ Accept: 'application/json', 'X-API-Key': key },
|
|
329
|
+
signal,
|
|
330
|
+
);
|
|
331
|
+
const hits: SearchHit[] = (d.results ?? [])
|
|
332
|
+
.filter((r) => r?.url)
|
|
333
|
+
.map((r) => ({ title: r.title ?? r.url!, url: r.url!, snippet: r.snippet ?? '', engine: 'tinyfish' }));
|
|
334
|
+
return { engine: 'tinyfish', hits, latencyMs: Date.now() - t0 };
|
|
335
|
+
} catch (e) {
|
|
336
|
+
return { engine: 'tinyfish', hits: [], error: sanitizeError(e), latencyMs: Date.now() - t0 };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const ENGINE_REGISTRY: EngineDef[] = [
|
|
341
|
+
{ id: 'serper', key: 'serperKey', available: (c) => !!c.serperKey, search: serper },
|
|
342
|
+
{ id: 'tavily', key: 'tavilyKey', available: (c) => !!c.tavilyKey, search: tavily },
|
|
343
|
+
{ id: 'exa', key: 'exaKey', available: (c) => !!c.exaKey, search: exa },
|
|
344
|
+
{ id: 'brave', key: 'braveKey', available: (c) => !!c.braveKey, search: brave },
|
|
345
|
+
{ id: 'jina', key: 'jinaKey', available: (c) => !!c.jinaKey, search: jina },
|
|
346
|
+
{ id: 'kagi', key: 'kagiKey', available: (c) => !!c.kagiKey, search: kagi },
|
|
347
|
+
{ id: 'you', key: 'youKey', available: (c) => !!c.youKey, search: you },
|
|
348
|
+
{ id: 'firecrawl', key: 'firecrawlKey', available: (c) => !!c.firecrawlKey, search: firecrawl },
|
|
349
|
+
{ id: 'tinyfish', key: 'tinyfishKey', available: (c) => !!c.tinyfishKey, search: tinyfish },
|
|
350
|
+
];
|
|
351
|
+
|
|
352
|
+
function parseEngineList(raw: string | undefined): EngineName[] | undefined {
|
|
353
|
+
if (!raw) return undefined;
|
|
354
|
+
const out: EngineName[] = [];
|
|
355
|
+
for (const s of raw.split(',')) {
|
|
356
|
+
const t = s.trim().toLowerCase();
|
|
357
|
+
if (!t) continue;
|
|
358
|
+
if (ENGINE_REGISTRY.some((e) => e.id === t)) out.push(t as EngineName);
|
|
359
|
+
}
|
|
360
|
+
return out.length ? out : undefined;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function selectEngines(opts: SearchOptions): EngineDef[] {
|
|
364
|
+
const cfg = getConfig();
|
|
365
|
+
const wanted = opts.engines ?? parseEngineList(cfg.searchEngines);
|
|
366
|
+
const selected = ENGINE_REGISTRY.filter((def) => {
|
|
367
|
+
if (!def.available(cfg)) return false;
|
|
368
|
+
return wanted ? wanted.includes(def.id) : true;
|
|
369
|
+
});
|
|
370
|
+
return selected;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export async function runEngines(opts: SearchOptions): Promise<EngineOutcome[]> {
|
|
374
|
+
const cfg = getConfig();
|
|
375
|
+
const chosen = selectEngines(opts);
|
|
376
|
+
if (chosen.length === 0) {
|
|
377
|
+
const wanted = opts.engines ?? parseEngineList(getConfig().searchEngines);
|
|
378
|
+
const error = wanted?.length
|
|
379
|
+
? `no configured engines match the requested set (${wanted.join(', ')})`
|
|
380
|
+
: 'no search engine keys configured; run /websearch to add one';
|
|
381
|
+
return [{ engine: 'none', hits: [], error, latencyMs: 0 }];
|
|
382
|
+
}
|
|
383
|
+
return Promise.all(chosen.map((def) => def.search(cfg[def.key] as string, opts, opts.signal)));
|
|
384
|
+
}
|
package/src/fuse.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type { EngineOutcome, SearchHit } from './engines';
|
|
2
|
+
import { isJunk, normalizeUrl, hostOf } from './urls';
|
|
3
|
+
|
|
4
|
+
export interface FusedHit extends SearchHit {
|
|
5
|
+
engines: string[];
|
|
6
|
+
rrfScore: number;
|
|
7
|
+
finalScore: number;
|
|
8
|
+
junk: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface FuseOptions {
|
|
12
|
+
query?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const RRF_K = 60;
|
|
16
|
+
// Tiny on purpose: RRF already rewards multi-engine agreement, and the bonus
|
|
17
|
+
// must never scale with how many engines happen to be configured.
|
|
18
|
+
const CONSENSUS_TIEBREAK = 0.003;
|
|
19
|
+
|
|
20
|
+
// Dense/semantic ranking dilutes rare technical tokens (CVE-2026-…, pgvector,
|
|
21
|
+
// error codes): a verbatim title/snippet hit gets a bounded nudge, never total.
|
|
22
|
+
const STOPWORDS = new Set([
|
|
23
|
+
'a',
|
|
24
|
+
'an',
|
|
25
|
+
'the',
|
|
26
|
+
'and',
|
|
27
|
+
'or',
|
|
28
|
+
'but',
|
|
29
|
+
'nor',
|
|
30
|
+
'for',
|
|
31
|
+
'so',
|
|
32
|
+
'yet',
|
|
33
|
+
'of',
|
|
34
|
+
'to',
|
|
35
|
+
'in',
|
|
36
|
+
'on',
|
|
37
|
+
'at',
|
|
38
|
+
'by',
|
|
39
|
+
'with',
|
|
40
|
+
'from',
|
|
41
|
+
'as',
|
|
42
|
+
'is',
|
|
43
|
+
'are',
|
|
44
|
+
'was',
|
|
45
|
+
'were',
|
|
46
|
+
'be',
|
|
47
|
+
'been',
|
|
48
|
+
'being',
|
|
49
|
+
'it',
|
|
50
|
+
'its',
|
|
51
|
+
'this',
|
|
52
|
+
'that',
|
|
53
|
+
'these',
|
|
54
|
+
'those',
|
|
55
|
+
'how',
|
|
56
|
+
'what',
|
|
57
|
+
'why',
|
|
58
|
+
'when',
|
|
59
|
+
'where',
|
|
60
|
+
'which',
|
|
61
|
+
'who',
|
|
62
|
+
'whom',
|
|
63
|
+
'do',
|
|
64
|
+
'does',
|
|
65
|
+
'did',
|
|
66
|
+
'can',
|
|
67
|
+
'could',
|
|
68
|
+
'should',
|
|
69
|
+
'would',
|
|
70
|
+
'may',
|
|
71
|
+
'might',
|
|
72
|
+
'must',
|
|
73
|
+
'not',
|
|
74
|
+
'no',
|
|
75
|
+
'yes',
|
|
76
|
+
'about',
|
|
77
|
+
'into',
|
|
78
|
+
'over',
|
|
79
|
+
'up',
|
|
80
|
+
'down',
|
|
81
|
+
'out',
|
|
82
|
+
'off',
|
|
83
|
+
'than',
|
|
84
|
+
'then',
|
|
85
|
+
'now',
|
|
86
|
+
'too',
|
|
87
|
+
'very',
|
|
88
|
+
'get',
|
|
89
|
+
'use',
|
|
90
|
+
'using',
|
|
91
|
+
'vs',
|
|
92
|
+
'versus',
|
|
93
|
+
'best',
|
|
94
|
+
'top',
|
|
95
|
+
'list',
|
|
96
|
+
'your',
|
|
97
|
+
'our',
|
|
98
|
+
'their',
|
|
99
|
+
'they',
|
|
100
|
+
'he',
|
|
101
|
+
'she',
|
|
102
|
+
'we',
|
|
103
|
+
'you',
|
|
104
|
+
'i',
|
|
105
|
+
'have',
|
|
106
|
+
'has',
|
|
107
|
+
'had',
|
|
108
|
+
'there',
|
|
109
|
+
'here',
|
|
110
|
+
'all',
|
|
111
|
+
'any',
|
|
112
|
+
'each',
|
|
113
|
+
'some',
|
|
114
|
+
'more',
|
|
115
|
+
'most',
|
|
116
|
+
'only',
|
|
117
|
+
'also',
|
|
118
|
+
'just',
|
|
119
|
+
's',
|
|
120
|
+
't',
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
function queryTerms(query: string): string[] {
|
|
124
|
+
const terms: string[] = [];
|
|
125
|
+
for (const w of query.toLowerCase().split(/[^a-z0-9]+/)) {
|
|
126
|
+
if (w.length < 3 || STOPWORDS.has(w)) continue;
|
|
127
|
+
terms.push(w);
|
|
128
|
+
}
|
|
129
|
+
return [...new Set(terms)].slice(0, 6);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function termIn(term: string, text: string): boolean {
|
|
133
|
+
return new RegExp(`(^|[^a-z0-9])${term}([^a-z0-9]|$)`).test(text);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function exactMatchBoost(hit: { title: string; snippet: string }, query: string | undefined): number {
|
|
137
|
+
if (!query) return 0;
|
|
138
|
+
const terms = queryTerms(query);
|
|
139
|
+
if (!terms.length) return 0;
|
|
140
|
+
const title = hit.title.toLowerCase();
|
|
141
|
+
const snip = hit.snippet.toLowerCase();
|
|
142
|
+
let matched = 0;
|
|
143
|
+
for (const t of terms) {
|
|
144
|
+
if (termIn(t, title)) matched += 1;
|
|
145
|
+
else if (termIn(t, snip)) matched += 0.5;
|
|
146
|
+
}
|
|
147
|
+
if (!matched) return 0;
|
|
148
|
+
return 0.3 * Math.min(1, matched / terms.length);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function normTitle(s: string): string {
|
|
152
|
+
return s
|
|
153
|
+
.toLowerCase()
|
|
154
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
155
|
+
.replace(/\b(a|an|the|and|or|for|of|to|in|on|at|with|by|from|is|are|was|were|this|that|these|those)\b/g, ' ')
|
|
156
|
+
.replace(/\s+/g, ' ')
|
|
157
|
+
.trim();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isNearDuplicate(a: FusedHit, b: FusedHit): boolean {
|
|
161
|
+
const A = normTitle(a.title);
|
|
162
|
+
const B = normTitle(b.title);
|
|
163
|
+
// dedupe both sides so a repeated word can't inflate the ratio past 1.0
|
|
164
|
+
const ta = [...new Set(A.split(' ').filter(Boolean))];
|
|
165
|
+
const tb = [...new Set(B.split(' ').filter(Boolean))];
|
|
166
|
+
if (ta.length < 3 || tb.length < 3) return false;
|
|
167
|
+
if (A === B) return true;
|
|
168
|
+
const inter = ta.filter((w) => tb.includes(w)).length;
|
|
169
|
+
const jacc = inter / Math.max(new Set([...ta, ...tb]).size, 1);
|
|
170
|
+
if (jacc >= 0.85) return true;
|
|
171
|
+
return (A.startsWith(B) || B.startsWith(A)) && Math.abs(ta.length - tb.length) <= 1;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function collapseNearDuplicates(fused: FusedHit[]): FusedHit[] {
|
|
175
|
+
const kept: FusedHit[] = [];
|
|
176
|
+
for (const h of fused) {
|
|
177
|
+
const twin = kept.find((k) => isNearDuplicate(k, h));
|
|
178
|
+
if (twin) {
|
|
179
|
+
twin.engines = [...new Set([...twin.engines, ...h.engines])];
|
|
180
|
+
if (h.snippet.length > twin.snippet.length) twin.snippet = h.snippet;
|
|
181
|
+
if (h.title.length > twin.title.length && !twin.title.includes(h.title)) twin.title = h.title;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
kept.push(h);
|
|
185
|
+
}
|
|
186
|
+
return kept;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function fuse(outcomes: EngineOutcome[], opts: FuseOptions = {}): FusedHit[] {
|
|
190
|
+
const { query } = opts;
|
|
191
|
+
const rank: Record<string, { hit: SearchHit; ranks: { engine: string; rank: number }[] }> = {};
|
|
192
|
+
let answering = 0;
|
|
193
|
+
for (const oc of outcomes) {
|
|
194
|
+
if (oc.error || !oc.hits.length) continue;
|
|
195
|
+
answering++;
|
|
196
|
+
oc.hits.forEach((h, i) => {
|
|
197
|
+
const key = normalizeUrl(h.url);
|
|
198
|
+
const slot = (rank[key] ??= { hit: { ...h }, ranks: [] });
|
|
199
|
+
slot.ranks.push({ engine: oc.engine, rank: i + 1 });
|
|
200
|
+
if (h.snippet.length > slot.hit.snippet.length) slot.hit.snippet = h.snippet;
|
|
201
|
+
if (!slot.hit.title && h.title) slot.hit.title = h.title;
|
|
202
|
+
slot.hit.date ??= h.date;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const fused: FusedHit[] = [];
|
|
206
|
+
for (const [, { hit, ranks }] of Object.entries(rank)) {
|
|
207
|
+
let rrf = 0;
|
|
208
|
+
for (const r of ranks) rrf += 1 / (RRF_K + r.rank);
|
|
209
|
+
const junk = isJunk(hit.url);
|
|
210
|
+
// agreement breaks only near-ties and is normalized by engines answered,
|
|
211
|
+
// so adding pockets never inflates scores
|
|
212
|
+
const consensus = answering > 0 ? ranks.length / answering : 0;
|
|
213
|
+
let score = rrf + CONSENSUS_TIEBREAK * consensus;
|
|
214
|
+
score *= 1 + exactMatchBoost(hit, query);
|
|
215
|
+
if (hit.snippet.trim().length < 30) score *= 0.92;
|
|
216
|
+
if (junk) score *= 0.25;
|
|
217
|
+
fused.push({
|
|
218
|
+
...hit,
|
|
219
|
+
engines: ranks.map((r) => r.engine),
|
|
220
|
+
rrfScore: rrf,
|
|
221
|
+
finalScore: Math.round(score * 1e5) / 1e5,
|
|
222
|
+
junk,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
const sorted = fused.toSorted((a, b) => b.finalScore - a.finalScore);
|
|
226
|
+
return collapseNearDuplicates(sorted);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function diversifyByHost(hits: FusedHit[], maxPerHost = 2): FusedHit[] {
|
|
230
|
+
const perHost = new Map<string, number>();
|
|
231
|
+
const out: FusedHit[] = [];
|
|
232
|
+
for (const h of hits) {
|
|
233
|
+
const host = hostOf(h.url);
|
|
234
|
+
const n = perHost.get(host) ?? 0;
|
|
235
|
+
if (n >= maxPerHost) continue;
|
|
236
|
+
perHost.set(host, n + 1);
|
|
237
|
+
out.push(h);
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|