pi-lightpanda-web 0.0.0-stage → 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 +661 -0
- package/README.md +244 -2
- package/agent-dir.ts +19 -0
- package/cache.ts +162 -0
- package/engines.ts +865 -0
- package/entities.ts +2399 -0
- package/html-to-md.ts +1267 -0
- package/index.ts +182 -0
- package/lightpanda.ts +448 -0
- package/local-file.ts +134 -0
- package/package.json +82 -4
- package/page-text.ts +5 -0
- package/pdf-fetch.ts +405 -0
- package/pdf.ts +785 -0
- package/proxy.ts +69 -0
- package/scripts/install-lightpanda.sh +294 -0
- package/search-config.ts +240 -0
- package/settings.ts +279 -0
- package/web-access.ts +346 -0
- package/web-fetch.ts +135 -0
- package/web-search.ts +156 -0
package/engines.ts
ADDED
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
import { collapseWhitespace, decodeHtmlEntities, feedHtml } from "./html-to-md.ts";
|
|
2
|
+
import type { AttrDict } from "./html-to-md.ts";
|
|
3
|
+
import type { WebsitePolicy } from "./web-access.ts";
|
|
4
|
+
|
|
5
|
+
export class EmptySweepError extends Error {
|
|
6
|
+
constructor() {
|
|
7
|
+
super("No results found");
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class SearchTimeoutError extends Error {
|
|
12
|
+
providers: string[];
|
|
13
|
+
constructor(providers: string[] = []) {
|
|
14
|
+
super("timed out");
|
|
15
|
+
this.providers = providers;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class SearchCancelled extends Error {
|
|
20
|
+
constructor() {
|
|
21
|
+
super("cancelled");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SearchResult {
|
|
26
|
+
title: string;
|
|
27
|
+
href: string;
|
|
28
|
+
body: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const STRIP_TAGS_RE = /<.*?>/g;
|
|
32
|
+
|
|
33
|
+
export function normalizeText(raw: string): string {
|
|
34
|
+
if (!raw) return "";
|
|
35
|
+
let text = raw.replace(STRIP_TAGS_RE, "");
|
|
36
|
+
text = decodeHtmlEntities(text);
|
|
37
|
+
text = text.normalize("NFC");
|
|
38
|
+
text = text.replace(/[\p{Cc}\p{Cf}\p{Co}\p{Cs}\p{Cn}]/gu, "");
|
|
39
|
+
return collapseWhitespace(text);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function normalizeUrl(url: string): string {
|
|
43
|
+
if (!url) return "";
|
|
44
|
+
try {
|
|
45
|
+
return decodeURIComponent(url).replace(/ /g, "+");
|
|
46
|
+
} catch {
|
|
47
|
+
return url.replace(/ /g, "+");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const TRACKING_PARAM_NAMES = new Set([
|
|
52
|
+
"_hsenc",
|
|
53
|
+
"_hsmi",
|
|
54
|
+
"dclid",
|
|
55
|
+
"fbclid",
|
|
56
|
+
"gbraid",
|
|
57
|
+
"gclid",
|
|
58
|
+
"gclsrc",
|
|
59
|
+
"igshid",
|
|
60
|
+
"mc_cid",
|
|
61
|
+
"mc_eid",
|
|
62
|
+
"msclkid",
|
|
63
|
+
"srsltid",
|
|
64
|
+
"twclid",
|
|
65
|
+
"wbraid",
|
|
66
|
+
"yclid",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
export function canonicalizeHref(href: string): string {
|
|
70
|
+
if (!href) return "";
|
|
71
|
+
try {
|
|
72
|
+
const url = new URL(href);
|
|
73
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
74
|
+
const lower = key.toLowerCase();
|
|
75
|
+
if (lower.startsWith("utm_") || TRACKING_PARAM_NAMES.has(lower)) {
|
|
76
|
+
url.searchParams.delete(key);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
url.hash = "";
|
|
80
|
+
if ((url.protocol === "http:" && url.port === "80") || (url.protocol === "https:" && url.port === "443")) {
|
|
81
|
+
url.port = "";
|
|
82
|
+
}
|
|
83
|
+
return url.toString();
|
|
84
|
+
} catch {
|
|
85
|
+
return href;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface DomNode {
|
|
90
|
+
tag: string;
|
|
91
|
+
attrs: Record<string, string>;
|
|
92
|
+
children: DomNode[];
|
|
93
|
+
textNodes: string[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function buildDom(html: string): DomNode {
|
|
97
|
+
const root: DomNode = { tag: "#root", attrs: {}, children: [], textNodes: [] };
|
|
98
|
+
const stack: DomNode[] = [root];
|
|
99
|
+
const pushText = (text: string) => {
|
|
100
|
+
for (const el of stack) el.textNodes.push(text);
|
|
101
|
+
};
|
|
102
|
+
feedHtml(html, {
|
|
103
|
+
handleStartTag(name: string, attrs: AttrDict) {
|
|
104
|
+
const el: DomNode = {
|
|
105
|
+
tag: name,
|
|
106
|
+
attrs: Object.fromEntries(
|
|
107
|
+
Object.entries(attrs).map(([key, value]) => [key, value ?? ""]),
|
|
108
|
+
),
|
|
109
|
+
children: [],
|
|
110
|
+
textNodes: [],
|
|
111
|
+
};
|
|
112
|
+
stack[stack.length - 1].children.push(el);
|
|
113
|
+
stack.push(el);
|
|
114
|
+
},
|
|
115
|
+
handleStartEndTag() {},
|
|
116
|
+
handleEndTag(name: string) {
|
|
117
|
+
for (let i = stack.length - 1; i >= 1; i--) {
|
|
118
|
+
if (stack[i].tag === name) {
|
|
119
|
+
stack.length = i;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
handleData(text: string) {
|
|
125
|
+
pushText(text);
|
|
126
|
+
},
|
|
127
|
+
handleEntityRef(name: string) {
|
|
128
|
+
pushText(decodeHtmlEntities(`&${name};`));
|
|
129
|
+
},
|
|
130
|
+
handleCharRef(name: string) {
|
|
131
|
+
pushText(decodeHtmlEntities(`&#${name};`));
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
return root;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
type Pred =
|
|
138
|
+
| { op: "or"; a: Pred; b: Pred }
|
|
139
|
+
| { op: "and"; a: Pred; b: Pred }
|
|
140
|
+
| { op: "last" }
|
|
141
|
+
| { op: "class-contains"; value: string }
|
|
142
|
+
| { op: "attr-eq"; name: string; value: string }
|
|
143
|
+
| { op: "has-attr"; name: string }
|
|
144
|
+
| { op: "desc"; tag: string }
|
|
145
|
+
| { op: "child"; tag: string; preds: Pred[] };
|
|
146
|
+
|
|
147
|
+
interface XStep {
|
|
148
|
+
axis: "descendant" | "child";
|
|
149
|
+
name?: string;
|
|
150
|
+
preds: Pred[];
|
|
151
|
+
terminal?: "text" | string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parsePredicateBlocks(input: string, start: number): { preds: Pred[]; next: number } {
|
|
155
|
+
const preds: Pred[] = [];
|
|
156
|
+
let pos = start;
|
|
157
|
+
while (pos < input.length && input[pos] === "[") {
|
|
158
|
+
const innerStart = pos + 1;
|
|
159
|
+
let depth = 1;
|
|
160
|
+
let quote: string | null = null;
|
|
161
|
+
let j = innerStart;
|
|
162
|
+
while (j < input.length && depth) {
|
|
163
|
+
const c = input[j];
|
|
164
|
+
if (quote !== null) {
|
|
165
|
+
if (c === quote) quote = null;
|
|
166
|
+
} else if (c === "'" || c === '"') {
|
|
167
|
+
quote = c;
|
|
168
|
+
} else if (c === "[") {
|
|
169
|
+
depth++;
|
|
170
|
+
} else if (c === "]") {
|
|
171
|
+
depth--;
|
|
172
|
+
}
|
|
173
|
+
j++;
|
|
174
|
+
}
|
|
175
|
+
preds.push(parsePredExpr(input.slice(innerStart, j - 1)));
|
|
176
|
+
pos = j;
|
|
177
|
+
}
|
|
178
|
+
return { preds, next: pos };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function parsePredExpr(input: string): Pred {
|
|
182
|
+
let pos = 0;
|
|
183
|
+
const ws = () => {
|
|
184
|
+
while (pos < input.length && /\s/.test(input[pos])) pos++;
|
|
185
|
+
};
|
|
186
|
+
const word = () => {
|
|
187
|
+
ws();
|
|
188
|
+
const m = /^[A-Za-z][A-Za-z0-9_-]*/.exec(input.slice(pos));
|
|
189
|
+
if (!m) throw new Error(`bad predicate: ${input}`);
|
|
190
|
+
pos += m[0].length;
|
|
191
|
+
return m[0];
|
|
192
|
+
};
|
|
193
|
+
const quoted = () => {
|
|
194
|
+
ws();
|
|
195
|
+
const quote = input[pos];
|
|
196
|
+
if (quote !== "'" && quote !== '"') throw new Error(`bad predicate quote: ${input}`);
|
|
197
|
+
pos++;
|
|
198
|
+
const end = input.indexOf(quote, pos);
|
|
199
|
+
if (end === -1) throw new Error(`bad predicate quote: ${input}`);
|
|
200
|
+
const value = input.slice(pos, end);
|
|
201
|
+
pos = end + 1;
|
|
202
|
+
return value;
|
|
203
|
+
};
|
|
204
|
+
const atom = (): Pred => {
|
|
205
|
+
ws();
|
|
206
|
+
if (input[pos] === "(") {
|
|
207
|
+
pos++;
|
|
208
|
+
const inner = parseOr();
|
|
209
|
+
ws();
|
|
210
|
+
if (input[pos] !== ")") throw new Error(`bad predicate paren: ${input}`);
|
|
211
|
+
pos++;
|
|
212
|
+
return inner;
|
|
213
|
+
}
|
|
214
|
+
if (input.startsWith("position()=last()", pos)) {
|
|
215
|
+
pos += "position()=last()".length;
|
|
216
|
+
return { op: "last" };
|
|
217
|
+
}
|
|
218
|
+
if (input.startsWith("last()", pos)) {
|
|
219
|
+
pos += "last()".length;
|
|
220
|
+
return { op: "last" };
|
|
221
|
+
}
|
|
222
|
+
if (input.startsWith("contains(@class,", pos)) {
|
|
223
|
+
pos += "contains(@class,".length;
|
|
224
|
+
const value = quoted();
|
|
225
|
+
ws();
|
|
226
|
+
if (input[pos] !== ")") throw new Error(`bad predicate contains: ${input}`);
|
|
227
|
+
pos++;
|
|
228
|
+
return { op: "class-contains", value };
|
|
229
|
+
}
|
|
230
|
+
if (input[pos] === "@") {
|
|
231
|
+
pos++;
|
|
232
|
+
const name = word();
|
|
233
|
+
ws();
|
|
234
|
+
if (input[pos] === "=") {
|
|
235
|
+
pos++;
|
|
236
|
+
const value = quoted();
|
|
237
|
+
return { op: "attr-eq", name, value };
|
|
238
|
+
}
|
|
239
|
+
return { op: "has-attr", name };
|
|
240
|
+
}
|
|
241
|
+
if (input.startsWith(".//", pos)) {
|
|
242
|
+
pos += 3;
|
|
243
|
+
const name = word();
|
|
244
|
+
return { op: "desc", tag: name };
|
|
245
|
+
}
|
|
246
|
+
if (input.startsWith("./", pos)) {
|
|
247
|
+
pos += 2;
|
|
248
|
+
const name = word();
|
|
249
|
+
const { preds, next } = parsePredicateBlocks(input, pos);
|
|
250
|
+
pos = next;
|
|
251
|
+
return { op: "child", tag: name, preds };
|
|
252
|
+
}
|
|
253
|
+
const name = word();
|
|
254
|
+
const { preds, next } = parsePredicateBlocks(input, pos);
|
|
255
|
+
pos = next;
|
|
256
|
+
return { op: "child", tag: name, preds };
|
|
257
|
+
};
|
|
258
|
+
const parseAnd = (): Pred => {
|
|
259
|
+
let left = atom();
|
|
260
|
+
while (true) {
|
|
261
|
+
ws();
|
|
262
|
+
if (input.startsWith("and", pos) && !/[A-Za-z0-9_]/.test(input[pos + 3] ?? "")) {
|
|
263
|
+
pos += 3;
|
|
264
|
+
left = { op: "and", a: left, b: atom() };
|
|
265
|
+
} else {
|
|
266
|
+
return left;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
const parseOr = (): Pred => {
|
|
271
|
+
let left = parseAnd();
|
|
272
|
+
while (true) {
|
|
273
|
+
ws();
|
|
274
|
+
if (input.startsWith("or", pos) && !/[A-Za-z0-9_]/.test(input[pos + 2] ?? "")) {
|
|
275
|
+
pos += 2;
|
|
276
|
+
left = { op: "or", a: left, b: parseAnd() };
|
|
277
|
+
} else {
|
|
278
|
+
return left;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
return parseOr();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function parsePath(expr: string): XStep[] {
|
|
286
|
+
const steps: XStep[] = [];
|
|
287
|
+
let i = 0;
|
|
288
|
+
let axis: "child" | "descendant" = "child";
|
|
289
|
+
if (expr.startsWith("//")) {
|
|
290
|
+
axis = "descendant";
|
|
291
|
+
i = 2;
|
|
292
|
+
} else if (expr.startsWith("./")) {
|
|
293
|
+
i = 2;
|
|
294
|
+
if (expr[i] === "/") {
|
|
295
|
+
axis = "descendant";
|
|
296
|
+
i++;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
while (i < expr.length) {
|
|
300
|
+
if (expr[i] === "/") {
|
|
301
|
+
if (expr[i + 1] === "/") {
|
|
302
|
+
axis = "descendant";
|
|
303
|
+
i += 2;
|
|
304
|
+
} else {
|
|
305
|
+
axis = "child";
|
|
306
|
+
i++;
|
|
307
|
+
}
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (expr[i] === ".") {
|
|
311
|
+
i++;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (expr[i] === "@") {
|
|
315
|
+
i++;
|
|
316
|
+
const m = /^[A-Za-z0-9_-]+/.exec(expr.slice(i));
|
|
317
|
+
steps.push({ axis, preds: [], terminal: m ? m[0] : "" });
|
|
318
|
+
i += m ? m[0].length : 0;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (expr.startsWith("text()", i)) {
|
|
322
|
+
steps.push({ axis, preds: [], terminal: "text" });
|
|
323
|
+
i += 6;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const m = /^[A-Za-z][A-Za-z0-9_-]*/.exec(expr.slice(i));
|
|
327
|
+
if (!m) break;
|
|
328
|
+
const name = m[0];
|
|
329
|
+
i += m[0].length;
|
|
330
|
+
const { preds, next } = parsePredicateBlocks(expr, i);
|
|
331
|
+
i = next;
|
|
332
|
+
steps.push({ axis, name, preds });
|
|
333
|
+
}
|
|
334
|
+
return steps;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function descendantsOf(el: DomNode): DomNode[] {
|
|
338
|
+
const out: DomNode[] = [];
|
|
339
|
+
const walk = (node: DomNode) => {
|
|
340
|
+
for (const child of node.children) {
|
|
341
|
+
out.push(child);
|
|
342
|
+
walk(child);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
walk(el);
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function matchesPred(pred: Pred, el: DomNode, index: number, total: number): boolean {
|
|
350
|
+
switch (pred.op) {
|
|
351
|
+
case "or":
|
|
352
|
+
return matchesPred(pred.a, el, index, total) || matchesPred(pred.b, el, index, total);
|
|
353
|
+
case "and":
|
|
354
|
+
return matchesPred(pred.a, el, index, total) && matchesPred(pred.b, el, index, total);
|
|
355
|
+
case "last":
|
|
356
|
+
return index === total - 1;
|
|
357
|
+
case "class-contains":
|
|
358
|
+
return (el.attrs["class"] ?? "").includes(pred.value);
|
|
359
|
+
case "attr-eq":
|
|
360
|
+
return el.attrs[pred.name] === pred.value;
|
|
361
|
+
case "has-attr":
|
|
362
|
+
return pred.name in el.attrs;
|
|
363
|
+
case "desc":
|
|
364
|
+
return el.tag === pred.tag || descendantsOf(el).some((d) => d.tag === pred.tag);
|
|
365
|
+
case "child":
|
|
366
|
+
return el.children.some(
|
|
367
|
+
(c) => c.tag === pred.tag && pred.preds.every((p) => matchesPred(p, c, 0, 1)),
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function predContainsLast(pred: Pred): boolean {
|
|
373
|
+
switch (pred.op) {
|
|
374
|
+
case "last":
|
|
375
|
+
return true;
|
|
376
|
+
case "or":
|
|
377
|
+
case "and":
|
|
378
|
+
return predContainsLast(pred.a) || predContainsLast(pred.b);
|
|
379
|
+
case "child":
|
|
380
|
+
return pred.preds.some(predContainsLast);
|
|
381
|
+
default:
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function applyStep(step: XStep, nodes: DomNode[]): DomNode[] {
|
|
387
|
+
if (step.preds.some(predContainsLast)) {
|
|
388
|
+
const out: DomNode[] = [];
|
|
389
|
+
const seen = new Set<DomNode>();
|
|
390
|
+
for (const node of nodes) {
|
|
391
|
+
const source = step.axis === "child" ? node.children : descendantsOf(node);
|
|
392
|
+
const list = source.filter((c) => !step.name || c.tag === step.name);
|
|
393
|
+
const total = list.length;
|
|
394
|
+
list.forEach((el, index) => {
|
|
395
|
+
if (seen.has(el)) return;
|
|
396
|
+
if (step.preds.every((p) => matchesPred(p, el, index, total))) {
|
|
397
|
+
seen.add(el);
|
|
398
|
+
out.push(el);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
return out;
|
|
403
|
+
}
|
|
404
|
+
const candidates: DomNode[] = [];
|
|
405
|
+
for (const node of nodes) {
|
|
406
|
+
const list = step.axis === "child" ? node.children : descendantsOf(node);
|
|
407
|
+
for (const c of list) {
|
|
408
|
+
if (step.name && c.tag !== step.name) continue;
|
|
409
|
+
candidates.push(c);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const deduped: DomNode[] = [];
|
|
413
|
+
const seen = new Set<DomNode>();
|
|
414
|
+
for (const c of candidates) {
|
|
415
|
+
if (!seen.has(c)) {
|
|
416
|
+
seen.add(c);
|
|
417
|
+
deduped.push(c);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
const total = deduped.length;
|
|
421
|
+
return deduped.filter((el, index) => step.preds.every((p) => matchesPred(p, el, index, total)));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function xpathText(expr: string, node: DomNode): string[] {
|
|
425
|
+
const steps = parsePath(expr);
|
|
426
|
+
let nodes: DomNode[] = [node];
|
|
427
|
+
for (const step of steps) {
|
|
428
|
+
if (step.terminal === "text") {
|
|
429
|
+
const out: string[] = [];
|
|
430
|
+
for (const n of nodes) out.push(...n.textNodes);
|
|
431
|
+
return out;
|
|
432
|
+
}
|
|
433
|
+
if (step.terminal !== undefined) {
|
|
434
|
+
return nodes.map((n) => n.attrs[step.terminal as string] ?? "");
|
|
435
|
+
}
|
|
436
|
+
nodes = applyStep(step, nodes);
|
|
437
|
+
}
|
|
438
|
+
return [];
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export function xpathNodes(expr: string, root: DomNode): DomNode[] {
|
|
442
|
+
const steps = parsePath(expr);
|
|
443
|
+
let nodes: DomNode[] = [root];
|
|
444
|
+
for (const step of steps) {
|
|
445
|
+
if (step.terminal) break;
|
|
446
|
+
nodes = applyStep(step, nodes);
|
|
447
|
+
}
|
|
448
|
+
return nodes;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function extractResults(
|
|
452
|
+
html: string,
|
|
453
|
+
itemsXpath: string,
|
|
454
|
+
elementsXpath: { title: string; href: string; body: string },
|
|
455
|
+
): SearchResult[] {
|
|
456
|
+
const root = buildDom(html);
|
|
457
|
+
const items = xpathNodes(itemsXpath, root);
|
|
458
|
+
const results: SearchResult[] = [];
|
|
459
|
+
for (const item of items) {
|
|
460
|
+
const result: SearchResult = { title: "", href: "", body: "" };
|
|
461
|
+
const entries = [
|
|
462
|
+
["title", elementsXpath.title],
|
|
463
|
+
["href", elementsXpath.href],
|
|
464
|
+
["body", elementsXpath.body],
|
|
465
|
+
] as const;
|
|
466
|
+
for (const [key, value] of entries) {
|
|
467
|
+
const data = collapseWhitespace(xpathText(value, item).join(""));
|
|
468
|
+
if (!data) continue;
|
|
469
|
+
result[key] = key === "href" ? normalizeUrl(data) : normalizeText(data);
|
|
470
|
+
}
|
|
471
|
+
if (!result.title && !result.href && !result.body) continue;
|
|
472
|
+
results.push(result);
|
|
473
|
+
}
|
|
474
|
+
return results;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export type SearchRenderPage = (
|
|
478
|
+
url: string,
|
|
479
|
+
options: {
|
|
480
|
+
timeoutMs: number;
|
|
481
|
+
signal?: AbortSignal;
|
|
482
|
+
websitePolicy?: WebsitePolicy | null;
|
|
483
|
+
allowPrivateAddresses?: boolean;
|
|
484
|
+
},
|
|
485
|
+
) => Promise<string | null>;
|
|
486
|
+
|
|
487
|
+
export interface SearchEngineOptions {
|
|
488
|
+
policy?: WebsitePolicy | null;
|
|
489
|
+
engines?: string[];
|
|
490
|
+
engineWeights?: Record<string, number>;
|
|
491
|
+
maxPerHost?: number;
|
|
492
|
+
renderPage?: SearchRenderPage;
|
|
493
|
+
allowPrivateAddresses?: boolean;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export interface EngineContext extends SearchEngineOptions {
|
|
497
|
+
region: string;
|
|
498
|
+
safesearch: string;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export interface Engine {
|
|
502
|
+
name: string;
|
|
503
|
+
provider: string;
|
|
504
|
+
render(
|
|
505
|
+
query: string,
|
|
506
|
+
ctx: EngineContext,
|
|
507
|
+
timeoutMs: number,
|
|
508
|
+
signal?: AbortSignal,
|
|
509
|
+
): Promise<SearchResult[] | null>;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function searchUrl(url: string, params: Record<string, string>): string {
|
|
513
|
+
const target = new URL(url);
|
|
514
|
+
for (const [key, value] of Object.entries(params)) target.searchParams.set(key, value);
|
|
515
|
+
return target.toString();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function renderEnginePage(
|
|
519
|
+
url: string,
|
|
520
|
+
ctx: EngineContext,
|
|
521
|
+
timeoutMs: number,
|
|
522
|
+
signal: AbortSignal | undefined,
|
|
523
|
+
parse: (html: string) => SearchResult[],
|
|
524
|
+
): Promise<SearchResult[] | null> {
|
|
525
|
+
if (!ctx.renderPage) return null;
|
|
526
|
+
const html = await ctx.renderPage(url, {
|
|
527
|
+
timeoutMs,
|
|
528
|
+
signal,
|
|
529
|
+
websitePolicy: ctx.policy ?? null,
|
|
530
|
+
allowPrivateAddresses: ctx.allowPrivateAddresses,
|
|
531
|
+
});
|
|
532
|
+
return html === null ? null : parse(html);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function decodeBingHref(href: string): string {
|
|
536
|
+
try {
|
|
537
|
+
const parsed = new URL(href);
|
|
538
|
+
if (!/(^|\.)bing\.com$/.test(parsed.hostname)) return href;
|
|
539
|
+
const raw = parsed.searchParams.get("u");
|
|
540
|
+
if (!raw || !raw.startsWith("a1")) return href;
|
|
541
|
+
const base64 = raw.slice(2).replace(/-/g, "+").replace(/_/g, "/");
|
|
542
|
+
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
|
|
543
|
+
const decoded = Buffer.from(padded, "base64").toString("utf8");
|
|
544
|
+
return /^https?:\/\//.test(decoded) ? decoded : href;
|
|
545
|
+
} catch {
|
|
546
|
+
return href;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function parseBingResults(html: string): SearchResult[] {
|
|
551
|
+
return extractResults(html, "//li[contains(@class, 'b_algo')]", {
|
|
552
|
+
title: ".//h2//text()",
|
|
553
|
+
href: ".//h2//a/@href",
|
|
554
|
+
body: ".//p//text()",
|
|
555
|
+
})
|
|
556
|
+
.map((result) => ({ ...result, href: decodeBingHref(result.href) }))
|
|
557
|
+
.filter((result) => result.title && /^https?:\/\//.test(result.href));
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function parseBraveResults(html: string): SearchResult[] {
|
|
561
|
+
return extractResults(html, "//div[contains(@class, 'result-wrapper')]", {
|
|
562
|
+
title: ".//div[contains(@class, 'title')]//text()",
|
|
563
|
+
href: ".//a[div[contains(@class, 'title')]]/@href",
|
|
564
|
+
body: ".//div[contains(@class, 'generic-snippet')]//text()",
|
|
565
|
+
}).filter((result) => result.title && /^https?:\/\//.test(result.href));
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function parseYahooResults(html: string): SearchResult[] {
|
|
569
|
+
return extractResults(html, "//li[.//h3[@class='title']]", {
|
|
570
|
+
title: ".//h3//text()",
|
|
571
|
+
href: ".//a[.//h3]/@href",
|
|
572
|
+
body: ".//div[contains(@class, 'compText')]//text()",
|
|
573
|
+
}).filter((result) => result.title && /^https?:\/\//.test(result.href));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function parseEcosiaResults(html: string): SearchResult[] {
|
|
577
|
+
return extractResults(html, "//article[@data-test-id='organic-result']", {
|
|
578
|
+
title: ".//h2[contains(@class, 'result-title__heading')]//text()",
|
|
579
|
+
href: ".//div[contains(@class, 'result__title')]//a/@href",
|
|
580
|
+
body: ".//p[contains(@class, 'web-result__description')]//text()",
|
|
581
|
+
}).filter((result) => result.title && /^https?:\/\//.test(result.href));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const BING: Engine = {
|
|
585
|
+
name: "bing",
|
|
586
|
+
provider: "bing",
|
|
587
|
+
render(query, ctx, timeoutMs, signal) {
|
|
588
|
+
return renderEnginePage(
|
|
589
|
+
searchUrl("https://www.bing.com/search", { q: query }),
|
|
590
|
+
ctx,
|
|
591
|
+
timeoutMs,
|
|
592
|
+
signal,
|
|
593
|
+
parseBingResults,
|
|
594
|
+
);
|
|
595
|
+
},
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
const BRAVE: Engine = {
|
|
599
|
+
name: "brave",
|
|
600
|
+
provider: "brave",
|
|
601
|
+
render(query, ctx, timeoutMs, signal) {
|
|
602
|
+
return renderEnginePage(
|
|
603
|
+
searchUrl("https://search.brave.com/search", { q: query }),
|
|
604
|
+
ctx,
|
|
605
|
+
timeoutMs,
|
|
606
|
+
signal,
|
|
607
|
+
parseBraveResults,
|
|
608
|
+
);
|
|
609
|
+
},
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
const YAHOO: Engine = {
|
|
613
|
+
name: "yahoo",
|
|
614
|
+
provider: "yahoo",
|
|
615
|
+
render(query, ctx, timeoutMs, signal) {
|
|
616
|
+
return renderEnginePage(
|
|
617
|
+
searchUrl("https://search.yahoo.com/search", { p: query }),
|
|
618
|
+
ctx,
|
|
619
|
+
timeoutMs,
|
|
620
|
+
signal,
|
|
621
|
+
parseYahooResults,
|
|
622
|
+
);
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
const ECOSIA: Engine = {
|
|
627
|
+
name: "ecosia",
|
|
628
|
+
provider: "ecosia",
|
|
629
|
+
render(query, ctx, timeoutMs, signal) {
|
|
630
|
+
return renderEnginePage(
|
|
631
|
+
searchUrl("https://www.ecosia.org/search", { q: query }),
|
|
632
|
+
ctx,
|
|
633
|
+
timeoutMs,
|
|
634
|
+
signal,
|
|
635
|
+
parseEcosiaResults,
|
|
636
|
+
);
|
|
637
|
+
},
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
export const TEXT_ENGINES: Engine[] = [BING, BRAVE, YAHOO, ECOSIA];
|
|
641
|
+
|
|
642
|
+
export const DEFAULT_ENGINE_NAMES = ["brave", "ecosia"] as const;
|
|
643
|
+
|
|
644
|
+
export class ResultsAggregator {
|
|
645
|
+
private cache = new Map<string, SearchResult>();
|
|
646
|
+
private scores = new Map<string, number>();
|
|
647
|
+
|
|
648
|
+
get size(): number {
|
|
649
|
+
return this.cache.size;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
append(item: SearchResult, weight = 1, rank = 1): void {
|
|
653
|
+
if (typeof item.href !== "string" || !item.href.trim()) return;
|
|
654
|
+
const key = canonicalizeHref(item.href);
|
|
655
|
+
if (!key) return;
|
|
656
|
+
const existing = this.cache.get(key);
|
|
657
|
+
if (!existing || item.body.length > existing.body.length) {
|
|
658
|
+
this.cache.set(key, { ...item, href: key });
|
|
659
|
+
}
|
|
660
|
+
this.scores.set(key, (this.scores.get(key) ?? 0) + weight / (RRF_RANK_CONSTANT + rank));
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
extend(items: SearchResult[], weight = 1): void {
|
|
664
|
+
const seen = new Set<string>();
|
|
665
|
+
items.forEach((item, index) => {
|
|
666
|
+
const key = canonicalizeHref(item.href);
|
|
667
|
+
if (!key) return;
|
|
668
|
+
const first = !seen.has(key);
|
|
669
|
+
seen.add(key);
|
|
670
|
+
this.append(item, first ? weight : 0, index + 1);
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
ranked(): SearchResult[] {
|
|
675
|
+
return [...this.cache.entries()]
|
|
676
|
+
.filter(([, doc]) => !isWikimediaCategory(doc))
|
|
677
|
+
.map(([key, doc]) => ({ doc, score: this.scores.get(key) ?? 0 }))
|
|
678
|
+
.sort((a, b) => b.score - a.score || a.doc.href.localeCompare(b.doc.href))
|
|
679
|
+
.map((entry) => entry.doc);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const RRF_RANK_CONSTANT = 60;
|
|
684
|
+
const DEFAULT_MAX_PER_HOST = 0;
|
|
685
|
+
const MULTI_PART_SUFFIXES = new Set(["co.uk", "org.uk", "com.au", "co.jp", "co.nz", "com.br", "co.in"]);
|
|
686
|
+
|
|
687
|
+
export function registrableDomain(url: string): string {
|
|
688
|
+
let hostname = "";
|
|
689
|
+
try {
|
|
690
|
+
hostname = new URL(url).hostname.toLowerCase().replace(/^www\./, "");
|
|
691
|
+
} catch {
|
|
692
|
+
return "";
|
|
693
|
+
}
|
|
694
|
+
const parts = hostname.split(".");
|
|
695
|
+
if (parts.length < 2) return hostname;
|
|
696
|
+
const lastTwo = parts.slice(-2).join(".");
|
|
697
|
+
return MULTI_PART_SUFFIXES.has(lastTwo) && parts.length >= 3 ? parts.slice(-3).join(".") : lastTwo;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
export function capByHost(docs: SearchResult[], limit: number, maxPerHost: number): SearchResult[] {
|
|
701
|
+
if (limit <= 0) return [];
|
|
702
|
+
const perHost = new Map<string, number>();
|
|
703
|
+
const capped: SearchResult[] = [];
|
|
704
|
+
const allowed = maxPerHost > 0 ? maxPerHost : Number.POSITIVE_INFINITY;
|
|
705
|
+
for (const doc of docs) {
|
|
706
|
+
const domain = registrableDomain(doc.href);
|
|
707
|
+
const used = perHost.get(domain) ?? 0;
|
|
708
|
+
if (used >= allowed) continue;
|
|
709
|
+
perHost.set(domain, used + 1);
|
|
710
|
+
capped.push(doc);
|
|
711
|
+
if (capped.length >= limit) break;
|
|
712
|
+
}
|
|
713
|
+
return capped;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function isWikimediaCategory(doc: SearchResult): boolean {
|
|
717
|
+
return doc.title.includes("Category:") && doc.title.includes("Wikimedia");
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function defaultEngines(): Engine[] {
|
|
721
|
+
const wanted = new Set<string>(DEFAULT_ENGINE_NAMES);
|
|
722
|
+
return TEXT_ENGINES.filter((engine) => wanted.has(engine.name));
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function selectedEngines(names: string[] | undefined): Engine[] {
|
|
726
|
+
const wanted = new Set((names?.length ? names : DEFAULT_ENGINE_NAMES).map((name) => name.trim().toLowerCase()));
|
|
727
|
+
const chosen = TEXT_ENGINES.filter((engine) => wanted.has(engine.name));
|
|
728
|
+
return chosen.length ? chosen : defaultEngines();
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function shuffledEngines(engines: Engine[]): Engine[] {
|
|
732
|
+
const shuffled = [...engines];
|
|
733
|
+
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
734
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
735
|
+
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
736
|
+
}
|
|
737
|
+
return shuffled;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
export async function autoTextSearch(
|
|
741
|
+
query: string,
|
|
742
|
+
maxResults: number,
|
|
743
|
+
timeoutMs: number,
|
|
744
|
+
signal?: AbortSignal,
|
|
745
|
+
options: SearchEngineOptions = {},
|
|
746
|
+
): Promise<SearchResult[]> {
|
|
747
|
+
const started = Date.now();
|
|
748
|
+
const engines = shuffledEngines(selectedEngines(options.engines));
|
|
749
|
+
const deadline = started + timeoutMs;
|
|
750
|
+
const aggregator = new ResultsAggregator();
|
|
751
|
+
const engineWeights = options.engineWeights ?? {};
|
|
752
|
+
const maxPerHost = options.maxPerHost ?? DEFAULT_MAX_PER_HOST;
|
|
753
|
+
const enough = () => capByHost(aggregator.ranked(), maxResults, maxPerHost).length >= maxResults;
|
|
754
|
+
const ctx: EngineContext = {
|
|
755
|
+
region: "us-en",
|
|
756
|
+
safesearch: "moderate",
|
|
757
|
+
policy: options.policy ?? null,
|
|
758
|
+
renderPage: options.renderPage,
|
|
759
|
+
allowPrivateAddresses: options.allowPrivateAddresses,
|
|
760
|
+
};
|
|
761
|
+
const controller = new AbortController();
|
|
762
|
+
let onAbort: (() => void) | undefined;
|
|
763
|
+
if (signal) {
|
|
764
|
+
if (signal.aborted) controller.abort();
|
|
765
|
+
else {
|
|
766
|
+
onAbort = () => controller.abort();
|
|
767
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
const timedOutProviders = new Set<string>();
|
|
771
|
+
let cancelled = false;
|
|
772
|
+
let deadlineReached = false;
|
|
773
|
+
const deadlineTimer = setTimeout(() => {
|
|
774
|
+
deadlineReached = true;
|
|
775
|
+
controller.abort();
|
|
776
|
+
}, Math.max(1, timeoutMs));
|
|
777
|
+
const maxWorkers = Math.min(engines.length, 3);
|
|
778
|
+
let i = 0;
|
|
779
|
+
const pending = new Set<Promise<void>>();
|
|
780
|
+
const run = async (engine: Engine) => {
|
|
781
|
+
if (controller.signal.aborted) {
|
|
782
|
+
if (signal?.aborted) cancelled = true;
|
|
783
|
+
else if (deadlineReached) timedOutProviders.add(engine.name);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
let results: SearchResult[] | null = null;
|
|
787
|
+
for (let attempt = 0; attempt < 2 && results === null; attempt++) {
|
|
788
|
+
if (controller.signal.aborted) {
|
|
789
|
+
if (signal?.aborted) cancelled = true;
|
|
790
|
+
else if (deadlineReached) timedOutProviders.add(engine.name);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
const attemptBudget = deadline - Date.now();
|
|
794
|
+
if (attemptBudget <= 0) {
|
|
795
|
+
timedOutProviders.add(engine.name);
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
const engineSignal = signal
|
|
799
|
+
? AbortSignal.any([signal, controller.signal])
|
|
800
|
+
: controller.signal;
|
|
801
|
+
try {
|
|
802
|
+
results = await engine.render(query, ctx, Math.max(1, attemptBudget), engineSignal);
|
|
803
|
+
} catch (e) {
|
|
804
|
+
if (e instanceof SearchCancelled) {
|
|
805
|
+
if (controller.signal.aborted && !signal?.aborted) return;
|
|
806
|
+
cancelled = true;
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if (e instanceof SearchTimeoutError) {
|
|
810
|
+
timedOutProviders.add(engine.name);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (controller.signal.aborted) {
|
|
814
|
+
if (signal?.aborted) cancelled = true;
|
|
815
|
+
else if (deadlineReached) timedOutProviders.add(engine.name);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
if (results === null && attempt === 0) {
|
|
820
|
+
const backoff = Math.min(250, Math.max(0, deadline - Date.now()));
|
|
821
|
+
if (backoff > 0) {
|
|
822
|
+
await new Promise<void>((resolve) => setTimeout(resolve, backoff));
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
if (results === null && Date.now() >= deadline) timedOutProviders.add(engine.name);
|
|
827
|
+
if (results && results.length) {
|
|
828
|
+
aggregator.extend(results, engineWeights[engine.name] ?? 1);
|
|
829
|
+
if (enough()) controller.abort();
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
while (i < engines.length || pending.size > 0) {
|
|
833
|
+
if (enough() || cancelled) {
|
|
834
|
+
controller.abort();
|
|
835
|
+
break;
|
|
836
|
+
}
|
|
837
|
+
while (i < engines.length && pending.size < maxWorkers) {
|
|
838
|
+
if (enough() || cancelled) {
|
|
839
|
+
controller.abort();
|
|
840
|
+
break;
|
|
841
|
+
}
|
|
842
|
+
const engine = engines[i++];
|
|
843
|
+
const task = run(engine);
|
|
844
|
+
pending.add(task);
|
|
845
|
+
void task.then(
|
|
846
|
+
() => {
|
|
847
|
+
pending.delete(task);
|
|
848
|
+
},
|
|
849
|
+
() => {
|
|
850
|
+
pending.delete(task);
|
|
851
|
+
},
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
if (pending.size === 0) break;
|
|
855
|
+
await Promise.race(pending);
|
|
856
|
+
}
|
|
857
|
+
await Promise.allSettled(pending);
|
|
858
|
+
clearTimeout(deadlineTimer);
|
|
859
|
+
if (onAbort && signal) signal.removeEventListener("abort", onAbort);
|
|
860
|
+
if (cancelled) throw new SearchCancelled();
|
|
861
|
+
const results = capByHost(aggregator.ranked(), maxResults, maxPerHost);
|
|
862
|
+
if (results.length) return results;
|
|
863
|
+
if (timedOutProviders.size) throw new SearchTimeoutError([...timedOutProviders].sort());
|
|
864
|
+
throw new EmptySweepError();
|
|
865
|
+
}
|