pi-webfind 0.5.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 +126 -0
- package/extensions/web-search.ts +662 -0
- package/lib/adapters.ts +228 -0
- package/lib/apis.ts +221 -0
- package/lib/cache.ts +160 -0
- package/lib/engine.ts +418 -0
- package/lib/extract.ts +320 -0
- package/lib/fetcher.ts +475 -0
- package/lib/pdf.ts +181 -0
- package/lib/rank.ts +169 -0
- package/package.json +56 -0
package/lib/engine.ts
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-engine free web search for pi — no API keys, no paid services.
|
|
3
|
+
*
|
|
4
|
+
* Engines (tried in order):
|
|
5
|
+
* 1. DuckDuckGo html endpoint (GET)
|
|
6
|
+
* 2. DuckDuckGo lite endpoint
|
|
7
|
+
* 3. DuckDuckGo html endpoint (POST) — often works when GET is challenged
|
|
8
|
+
* 4. Brave Search HTML scraping — independent index, good redundancy
|
|
9
|
+
*
|
|
10
|
+
* All engines are scraped with Node's built-in fetch. Requests are
|
|
11
|
+
* rate-limited globally (1 / 1.2s per host) and results are cached on disk
|
|
12
|
+
* (JSON snapshot under ~/.pi/agent/cache/webfind, 10 min TTL).
|
|
13
|
+
*/
|
|
14
|
+
import { createDiskBackedCache } from "./cache.ts";
|
|
15
|
+
|
|
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";
|
|
18
|
+
// r.jina.ai blocks fake browser UAs but allows honest tool UAs (opposite of most sites)
|
|
19
|
+
const TOOL_UA = "pi-webfind/0.5 (free web research toolkit for pi coding agent; +https://github.com/jawwadzafar/pi-webfind)";
|
|
20
|
+
const TIMEOUT_MS = 15_000;
|
|
21
|
+
|
|
22
|
+
export interface SearchResult {
|
|
23
|
+
title: string;
|
|
24
|
+
url: string;
|
|
25
|
+
snippet: string;
|
|
26
|
+
engine: string;
|
|
27
|
+
/** Publication date when the source surface provides one (ISO or human-readable). */
|
|
28
|
+
date?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------- utilities
|
|
32
|
+
|
|
33
|
+
const lastHit = new Map<string, number>();
|
|
34
|
+
async function throttle(host: string, signal?: AbortSignal) {
|
|
35
|
+
const key = host;
|
|
36
|
+
const wait = (lastHit.get(key) ?? 0) + 1200 - Date.now();
|
|
37
|
+
if (wait > 0) await sleep(wait, signal);
|
|
38
|
+
lastHit.set(key, Date.now());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const t = setTimeout(resolve, ms);
|
|
44
|
+
signal?.addEventListener(
|
|
45
|
+
"abort",
|
|
46
|
+
() => {
|
|
47
|
+
clearTimeout(t);
|
|
48
|
+
reject(signal.reason ?? new Error("aborted"));
|
|
49
|
+
},
|
|
50
|
+
{ once: true },
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const NAMED_ENTITIES: Record<string, string> = {
|
|
56
|
+
amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", copy: "©", reg: "®", trade: "™",
|
|
57
|
+
hellip: "…", mdash: "—", ndash: "–", lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”",
|
|
58
|
+
laquo: "«", raquo: "»", times: "×", middot: "·", bull: "•", deg: "°", plusmn: "±", eacute: "é",
|
|
59
|
+
egrave: "è", agrave: "à", ccedil: "ç", uuml: "ü", ouml: "ö", auml: "ä", szlig: "ß", euro: "€",
|
|
60
|
+
trade_sup2: "²", dagger: "†", permil: "‰", prime: "′", Prime: "″", larr: "←", rarr: "→", uarr: "↑", darr: "↓", harr: "↔",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export function decodeEntities(s: string): string {
|
|
64
|
+
return s
|
|
65
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
|
66
|
+
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
|
|
67
|
+
.replace(/&([a-zA-Z][a-zA-Z0-9]*);/g, (ent, name: string) => NAMED_ENTITIES[name] ?? ent)
|
|
68
|
+
.replace(/"/g, '"')
|
|
69
|
+
.replace(/</g, "<")
|
|
70
|
+
.replace(/>/g, ">")
|
|
71
|
+
.replace(/&/g, "&");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const stripTags = (s: string) =>
|
|
75
|
+
decodeEntities(s.replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim();
|
|
76
|
+
|
|
77
|
+
function unwrapRedirect(href: string): string | null {
|
|
78
|
+
const m = href.match(/[?&]uddg=([^&]+)/);
|
|
79
|
+
if (m) return decodeURIComponent(m[1]);
|
|
80
|
+
if (href.startsWith("//")) return "https:" + href;
|
|
81
|
+
if (/^https?:\/\//.test(href)) return href;
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function get(url: string, signal?: AbortSignal, post?: string): Promise<string> {
|
|
86
|
+
const host = new URL(url).host;
|
|
87
|
+
await throttle(host, signal);
|
|
88
|
+
const timeout = AbortSignal.timeout(TIMEOUT_MS);
|
|
89
|
+
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
90
|
+
const res = await fetch(url, {
|
|
91
|
+
method: post ? "POST" : "GET",
|
|
92
|
+
headers: {
|
|
93
|
+
"User-Agent": UA,
|
|
94
|
+
Accept: "text/html,application/xhtml+xml",
|
|
95
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
96
|
+
...(post ? { "Content-Type": "application/x-www-form-urlencoded" } : {}),
|
|
97
|
+
},
|
|
98
|
+
body: post,
|
|
99
|
+
signal: combined,
|
|
100
|
+
redirect: "follow",
|
|
101
|
+
});
|
|
102
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
103
|
+
return res.text();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ------------------------------------------------------------------ engines
|
|
107
|
+
|
|
108
|
+
function parseDdgHtml(page: string): SearchResult[] {
|
|
109
|
+
const out: SearchResult[] = [];
|
|
110
|
+
const snippets = [...page.matchAll(/<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g)].map(
|
|
111
|
+
(m) => stripTags(m[1]),
|
|
112
|
+
);
|
|
113
|
+
// optional per-result date stamp (html endpoint emits it when the source provides one)
|
|
114
|
+
const timestamps = [...page.matchAll(/class="result__timestamp"[^>]*>([\s\S]*?)<\/a>/g)].map((m) => stripTags(m[1]));
|
|
115
|
+
let i = 0;
|
|
116
|
+
for (const m of page.matchAll(
|
|
117
|
+
/<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g,
|
|
118
|
+
)) {
|
|
119
|
+
const url = unwrapRedirect(m[1]);
|
|
120
|
+
if (!url) continue;
|
|
121
|
+
const date = timestamps[i]?.trim();
|
|
122
|
+
out.push({ title: stripTags(m[2]), url, snippet: snippets[i] ?? "", engine: "ddg", ...(date ? { date } : {}) });
|
|
123
|
+
i++;
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseDdgLite(page: string): SearchResult[] {
|
|
129
|
+
const links = [
|
|
130
|
+
...page.matchAll(/<a[^>]*class="result-link"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g),
|
|
131
|
+
];
|
|
132
|
+
const snippets = [...page.matchAll(/class="result-snippet"[^>]*>([\s\S]*?)<\/td>/g)].map((m) =>
|
|
133
|
+
stripTags(m[1]),
|
|
134
|
+
);
|
|
135
|
+
const out: SearchResult[] = [];
|
|
136
|
+
links.forEach((m, i) => {
|
|
137
|
+
const url = unwrapRedirect(m[1]);
|
|
138
|
+
if (!url) return;
|
|
139
|
+
out.push({ title: stripTags(m[2]), url, snippet: snippets[i] ?? "", engine: "ddg-lite" });
|
|
140
|
+
});
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseBrave(page: string): SearchResult[] {
|
|
145
|
+
// Brave svelte markup: results are <a href="https://..." class="svelte-... l1">
|
|
146
|
+
const out: SearchResult[] = [];
|
|
147
|
+
const seen = new Set<string>();
|
|
148
|
+
for (const m of page.matchAll(
|
|
149
|
+
/<a[^>]*href="(https?:\/\/[^"]+)"[^>]*class="[^"]*\bsvelte-[^"]*l1[^"]*"[^>]*>([\s\S]*?)<\/a>/g,
|
|
150
|
+
)) {
|
|
151
|
+
const url = unwrapRedirect(m[1]);
|
|
152
|
+
if (!url || seen.has(url) || /search\.brave\.com|brave\.com\/search/.test(url)) continue;
|
|
153
|
+
seen.add(url);
|
|
154
|
+
out.push({ title: stripTags(m[2]) || url, url, snippet: "", engine: "brave" });
|
|
155
|
+
}
|
|
156
|
+
// Fallback: any external anchor with heading-like content
|
|
157
|
+
if (out.length === 0) {
|
|
158
|
+
for (const m of page.matchAll(/<a[^>]*href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/g)) {
|
|
159
|
+
const url = unwrapRedirect(m[1]);
|
|
160
|
+
const title = stripTags(m[2]);
|
|
161
|
+
if (
|
|
162
|
+
!url ||
|
|
163
|
+
seen.has(url) ||
|
|
164
|
+
/brave\.com|imgs=|\/search\?|\.svg|\.css|\.js/.test(url) ||
|
|
165
|
+
title.length < 15
|
|
166
|
+
)
|
|
167
|
+
continue;
|
|
168
|
+
seen.add(url);
|
|
169
|
+
out.push({ title, url, snippet: "", engine: "brave" });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type Recency = "d" | "w" | "m" | "y" | undefined;
|
|
176
|
+
|
|
177
|
+
// ------------------------------------------- jina reader proxy (keyless)
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* r.jina.ai — keyless reader proxy with its own IP pool and headless browser.
|
|
181
|
+
* Used as an anti-blocking hop: different IPs than ours, executes JS,
|
|
182
|
+
* and can relay search-engine HTML when our direct requests get walled.
|
|
183
|
+
* Rate limit (keyless): ~20 req/min per IP — only used as fallback.
|
|
184
|
+
*/
|
|
185
|
+
const JINA_RATE_MS = 3_500;
|
|
186
|
+
let lastJina = 0;
|
|
187
|
+
|
|
188
|
+
async function jinaGet(url: string, signal?: AbortSignal): Promise<string> {
|
|
189
|
+
const wait = lastJina + JINA_RATE_MS - Date.now();
|
|
190
|
+
if (wait > 0) await sleep(wait, signal);
|
|
191
|
+
lastJina = Date.now();
|
|
192
|
+
const timeout = AbortSignal.timeout(30_000);
|
|
193
|
+
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
194
|
+
const res = await fetch(`https://r.jina.ai/${url}`, {
|
|
195
|
+
headers: { "User-Agent": TOOL_UA, Accept: "text/plain" },
|
|
196
|
+
signal: combined,
|
|
197
|
+
});
|
|
198
|
+
if (!res.ok) throw new Error(`jina HTTP ${res.status}`);
|
|
199
|
+
return res.text();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Parse r.jina.ai's markdown output of a DDG html/lite page into results. */
|
|
203
|
+
function parseJinaDdg(md: string): SearchResult[] {
|
|
204
|
+
const out: SearchResult[] = [];
|
|
205
|
+
const seen = new Set<string>();
|
|
206
|
+
// 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();
|
|
209
|
+
const url = unwrapRedirect(m[2]);
|
|
210
|
+
if (!url || seen.has(url)) continue;
|
|
211
|
+
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 } : {}) });
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function ddgSearch(
|
|
223
|
+
query: string,
|
|
224
|
+
maxResults: number,
|
|
225
|
+
recency: Recency,
|
|
226
|
+
signal?: AbortSignal,
|
|
227
|
+
): Promise<SearchResult[]> {
|
|
228
|
+
const params = new URLSearchParams({ q: query });
|
|
229
|
+
if (recency) params.set("df", recency);
|
|
230
|
+
const enc = params.toString();
|
|
231
|
+
let directFailed = false;
|
|
232
|
+
|
|
233
|
+
// 0. jina proxy (different IP pool — works when our IP is rate-limited)
|
|
234
|
+
try {
|
|
235
|
+
const results = parseJinaDdg(await jinaGet(`https://html.duckduckgo.com/html/?${enc}`, signal));
|
|
236
|
+
if (results.length > 0) return results.slice(0, maxResults);
|
|
237
|
+
} catch {
|
|
238
|
+
/* try next */
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 1. html GET
|
|
242
|
+
try {
|
|
243
|
+
const results = parseDdgHtml(await get(`https://html.duckduckgo.com/html/?${enc}`, signal));
|
|
244
|
+
if (results.length > 0) return results.slice(0, maxResults);
|
|
245
|
+
directFailed = true;
|
|
246
|
+
} catch {
|
|
247
|
+
directFailed = true;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 2. lite GET
|
|
251
|
+
try {
|
|
252
|
+
const results = parseDdgLite(await get(`https://lite.duckduckgo.com/lite/?${enc}`, signal));
|
|
253
|
+
if (results.length > 0) return results.slice(0, maxResults);
|
|
254
|
+
} catch {
|
|
255
|
+
/* try next */
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 3. html POST (often bypasses GET challenges)
|
|
259
|
+
try {
|
|
260
|
+
const results = parseDdgHtml(await get("https://html.duckduckgo.com/html/", signal, enc));
|
|
261
|
+
if (results.length > 0) return results.slice(0, maxResults);
|
|
262
|
+
} catch {
|
|
263
|
+
/* fall to jina */
|
|
264
|
+
}
|
|
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
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function braveSearch(
|
|
273
|
+
query: string,
|
|
274
|
+
maxResults: number,
|
|
275
|
+
recency: Recency,
|
|
276
|
+
signal?: AbortSignal,
|
|
277
|
+
): Promise<SearchResult[]> {
|
|
278
|
+
const params = new URLSearchParams({ q: query });
|
|
279
|
+
if (recency) {
|
|
280
|
+
const map: Record<string, string> = { d: "pd", w: "pw", m: "pm", y: "py" };
|
|
281
|
+
params.set("tf", map[recency]);
|
|
282
|
+
}
|
|
283
|
+
const results = parseBrave(await get(`https://search.brave.com/search?${params}`, signal));
|
|
284
|
+
if (results.length === 0) throw new Error("no results from brave");
|
|
285
|
+
return results.slice(0, maxResults);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function parseRssItems(xml: string, engine: string): SearchResult[] {
|
|
289
|
+
const out: SearchResult[] = [];
|
|
290
|
+
for (const m of xml.matchAll(/<item>([\s\S]*?)<\/item>/g)) {
|
|
291
|
+
const item = m[1];
|
|
292
|
+
const title = stripTags(item.match(/<title>([\s\S]*?)<\/title>/)?.[1] ?? "");
|
|
293
|
+
const url = (item.match(/<link>([\s\S]*?)<\/link>/)?.[1] ?? "").trim();
|
|
294
|
+
const snippet = stripTags(item.match(/<description>([\s\S]*?)<\/description>/)?.[1] ?? "");
|
|
295
|
+
if (!title || !/^https?:\/\//.test(url)) continue;
|
|
296
|
+
const pubRaw = (item.match(/<pubDate>([\s\S]*?)<\/pubDate>/)?.[1] ?? "").trim();
|
|
297
|
+
let date: string | undefined;
|
|
298
|
+
if (pubRaw) {
|
|
299
|
+
const ts = Date.parse(pubRaw);
|
|
300
|
+
if (!Number.isNaN(ts)) date = new Date(ts).toISOString().slice(0, 10);
|
|
301
|
+
}
|
|
302
|
+
out.push({ title, url, snippet, engine, ...(date ? { date } : {}) });
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Bing RSS — structured endpoint, real web index, no bot challenges. */
|
|
308
|
+
export async function bingRssSearch(
|
|
309
|
+
query: string,
|
|
310
|
+
maxResults: number,
|
|
311
|
+
recency: Recency,
|
|
312
|
+
signal?: AbortSignal,
|
|
313
|
+
): Promise<SearchResult[]> {
|
|
314
|
+
const params = new URLSearchParams({
|
|
315
|
+
q: query,
|
|
316
|
+
format: "rss",
|
|
317
|
+
count: String(Math.max(maxResults, 15)),
|
|
318
|
+
setmkt: "en-US",
|
|
319
|
+
setlang: "en",
|
|
320
|
+
});
|
|
321
|
+
if (recency) params.set("qdr", recency); // bing supports freshness via qdr on html; harmless on rss
|
|
322
|
+
const xml = await get(`https://www.bing.com/search?${params}`, signal);
|
|
323
|
+
const results = parseRssItems(xml, "bing");
|
|
324
|
+
if (results.length === 0) throw new Error("no results from bing rss");
|
|
325
|
+
return results.slice(0, maxResults);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Run several engines in parallel; RRF-fuse, dedupe by URL, consensus ranks first. */
|
|
329
|
+
export async function multiSearch(
|
|
330
|
+
query: string,
|
|
331
|
+
maxResults: number,
|
|
332
|
+
recency: Recency,
|
|
333
|
+
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
|
+
];
|
|
340
|
+
const settled = await Promise.allSettled(attempts.map((a) => a.fn()));
|
|
341
|
+
const namedBuckets: Array<{ name: string; rows: SearchResult[] }> = [];
|
|
342
|
+
const engines: string[] = [];
|
|
343
|
+
const errors: string[] = [];
|
|
344
|
+
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}`);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
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 };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// -------------------------------------------------------------------- cache
|
|
373
|
+
|
|
374
|
+
const CACHE_TTL = 10 * 60 * 1000;
|
|
375
|
+
const cache = createDiskBackedCache({ name: "search", maxEntries: 256, ttlMs: CACHE_TTL });
|
|
376
|
+
|
|
377
|
+
export function cacheGet(key: string): SearchResult[] | null {
|
|
378
|
+
const hit = cache.get(key);
|
|
379
|
+
return Array.isArray(hit) && hit.length > 0 ? (hit as SearchResult[]) : null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function cacheSet(key: string, value: SearchResult[]) {
|
|
383
|
+
if (value.length > 0) cache.set(key, value);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ----------------------------------------------------- page text extraction
|
|
387
|
+
|
|
388
|
+
/** HTML → readable text. Prefers <article>/<main>/content markup; strips chrome. */
|
|
389
|
+
export function htmlToText(page: string, maxChars: number): { text: string; truncated: boolean } {
|
|
390
|
+
const titleMatch = page.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
391
|
+
const title = titleMatch ? decodeEntities(titleMatch[1]).trim() : "";
|
|
392
|
+
|
|
393
|
+
let scope = page;
|
|
394
|
+
const articleMatch =
|
|
395
|
+
page.match(/<article[^>]*>([\s\S]*?)<\/article>/i) ??
|
|
396
|
+
page.match(/<main[^>]*>([\s\S]*?)<\/main>/i) ??
|
|
397
|
+
page.match(/<(?:div|section)[^>]*(?:id|class)="[^"]*(?:content|article|post|entry)[^"]*"[^>]*>([\s\S]*?)<\/(?:div|section)>/i);
|
|
398
|
+
// Only scope down when the candidate is substantial relative to the page
|
|
399
|
+
if (articleMatch && articleMatch[1].length > page.length * 0.1) scope = articleMatch[1];
|
|
400
|
+
|
|
401
|
+
const text = scope
|
|
402
|
+
.replace(/<(script|style|noscript|template|svg|iframe|nav|footer|header|aside|form)[^>]*>[\s\S]*?<\/\1>/gi, " ")
|
|
403
|
+
.replace(/<!--[\s\S]*?-->/g, " ")
|
|
404
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
405
|
+
.replace(/<\/(p|div|li|tr|h[1-6]|section|article|blockquote|pre|td)>/gi, "\n")
|
|
406
|
+
.replace(/<li[^>]*>/gi, "• ")
|
|
407
|
+
.replace(/<[^>]+>/g, " ");
|
|
408
|
+
const body = decodeEntities(text)
|
|
409
|
+
.split("\n")
|
|
410
|
+
.map((line) => line.replace(/[ \t]+/g, " ").trim())
|
|
411
|
+
.join("\n")
|
|
412
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
413
|
+
.replace(/\n /g, "\n")
|
|
414
|
+
.trim();
|
|
415
|
+
const full = title ? `${title}\n${"=".repeat(Math.min(title.length, 60))}\n${body}` : body;
|
|
416
|
+
const truncated = full.length > maxChars;
|
|
417
|
+
return { text: truncated ? full.slice(0, maxChars) : full, truncated };
|
|
418
|
+
}
|