@mammothb/pi-web 6.0.2 → 6.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.
@@ -0,0 +1,1346 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { Parser } from "htmlparser2";
3
+ import { formatSearchResults as sharedFormatSearchResults } from "../format";
4
+
5
+ // ── Normalizers (port of engines.ts) ──────────────────────────────────────
6
+
7
+ const STRIP_TAGS_RE = /<.*?>/g;
8
+
9
+ /**
10
+ * Minimal HTML entity decoder for the snippet paths.
11
+ * htmlparser2 already decodes entities in buildDom; this handles any leftover
12
+ * encoded fragments in title/body strings to match upstream parity.
13
+ */
14
+ function decodeHtmlEntities(text: string): string {
15
+ return text
16
+ .replace(/&#x([0-9a-fA-F]+);?/g, (_m: string, hex: string) => {
17
+ const cp = Number.parseInt(hex, 16);
18
+ if (Number.isNaN(cp) || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) {
19
+ return "\uFFFD";
20
+ }
21
+ return String.fromCodePoint(cp);
22
+ })
23
+ .replace(/&#(\d+);?/g, (_m: string, dec: string) => {
24
+ const cp = Number.parseInt(dec, 10);
25
+ if (Number.isNaN(cp) || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) {
26
+ return "\uFFFD";
27
+ }
28
+ return String.fromCodePoint(cp);
29
+ })
30
+ .replace(/&amp;/g, "&")
31
+ .replace(/&lt;/g, "<")
32
+ .replace(/&gt;/g, ">")
33
+ .replace(/&quot;/g, '"')
34
+ .replace(/&apos;/g, "'")
35
+ .replace(/&nbsp;/g, " ");
36
+ }
37
+
38
+ export function normalizeText(raw: string): string {
39
+ if (!raw) {
40
+ return "";
41
+ }
42
+ let text = raw.replace(STRIP_TAGS_RE, "");
43
+ text = decodeHtmlEntities(text);
44
+ text = text.normalize("NFC");
45
+ // Preserve word boundaries: whitespace controls become spaces before stripping remaining controls
46
+ text = text.replace(/[\t\n\r]/g, " ");
47
+ text = text.replace(/[\p{Cc}\p{Cf}\p{Co}\p{Cs}\p{Cn}]/gu, "");
48
+ return text.trim().split(/\s+/).join(" ");
49
+ }
50
+
51
+ export function normalizeUrl(url: string): string {
52
+ if (!url) {
53
+ return "";
54
+ }
55
+ try {
56
+ return decodeURIComponent(url).replace(/ /g, "+");
57
+ } catch {
58
+ return url.replace(/ /g, "+");
59
+ }
60
+ }
61
+
62
+ // ── DomNode + htmlparser2 adapter ─────────────────────────────────────────
63
+
64
+ export interface DomNode {
65
+ tag: string;
66
+ attrs: Record<string, string>;
67
+ children: DomNode[];
68
+ textNodes: string[];
69
+ }
70
+
71
+ export interface SearchResult {
72
+ title: string;
73
+ href: string;
74
+ body: string;
75
+ }
76
+
77
+ export function buildDom(html: string): DomNode {
78
+ const root: DomNode = {
79
+ tag: "#root",
80
+ attrs: {},
81
+ children: [],
82
+ textNodes: [],
83
+ };
84
+ const stack: DomNode[] = [root];
85
+ const pushText = (text: string) => {
86
+ for (const el of stack) {
87
+ el.textNodes.push(text);
88
+ }
89
+ };
90
+ const parser = new Parser(
91
+ {
92
+ onopentag(name: string, attribs: Record<string, string>) {
93
+ const el: DomNode = {
94
+ tag: name,
95
+ attrs: { ...attribs },
96
+ children: [],
97
+ textNodes: [],
98
+ };
99
+ // biome-ignore lint/style/noNonNullAssertion: stack always has root
100
+ stack[stack.length - 1]!.children.push(el);
101
+ stack.push(el);
102
+ },
103
+ ontext(data: string) {
104
+ pushText(data);
105
+ },
106
+ onclosetag(name: string) {
107
+ for (let i = stack.length - 1; i >= 1; i--) {
108
+ // biome-ignore lint/style/noNonNullAssertion: i in bounds
109
+ if (stack[i]!.tag === name) {
110
+ stack.length = i;
111
+ return;
112
+ }
113
+ }
114
+ },
115
+ oncomment() {},
116
+ },
117
+ {
118
+ decodeEntities: true,
119
+ lowerCaseTags: true,
120
+ lowerCaseAttributeNames: true,
121
+ },
122
+ );
123
+ parser.write(html);
124
+ parser.end();
125
+ return root;
126
+ }
127
+
128
+ // ── XPath subset (verbatim port of engines.ts) ────────────────────────────
129
+
130
+ type Pred =
131
+ | { op: "or"; a: Pred; b: Pred }
132
+ | { op: "and"; a: Pred; b: Pred }
133
+ | { op: "last" }
134
+ | { op: "class-contains"; value: string }
135
+ | { op: "attr-eq"; name: string; value: string }
136
+ | { op: "has-attr"; name: string }
137
+ | { op: "desc"; tag: string }
138
+ | { op: "child"; tag: string; preds: Pred[] };
139
+
140
+ type XStep =
141
+ | { kind: "node"; axis: "descendant" | "child"; name?: string; preds: Pred[] }
142
+ | { kind: "text" }
143
+ | { kind: "attr"; name: string };
144
+
145
+ function skipWhitespace(input: string, pos: { value: number }): void {
146
+ while (pos.value < input.length && /\s/.test(input[pos.value] as string)) {
147
+ pos.value++;
148
+ }
149
+ }
150
+
151
+ function expectChar(
152
+ input: string,
153
+ pos: { value: number },
154
+ char: string,
155
+ what: string,
156
+ ): void {
157
+ skipWhitespace(input, pos);
158
+ if (input[pos.value] !== char) {
159
+ throw new Error(`bad predicate ${what}: ${input}`);
160
+ }
161
+ pos.value++;
162
+ }
163
+
164
+ function readWord(input: string, pos: { value: number }): string {
165
+ skipWhitespace(input, pos);
166
+ const m = /^[A-Za-z][A-Za-z0-9_-]*/.exec(input.slice(pos.value));
167
+ if (!m) {
168
+ throw new Error(`bad predicate: ${input}`);
169
+ }
170
+ pos.value += m[0].length;
171
+ return m[0];
172
+ }
173
+
174
+ function readQuoted(input: string, pos: { value: number }): string {
175
+ skipWhitespace(input, pos);
176
+ const quote = input[pos.value] as string;
177
+ if (quote !== "'" && quote !== '"') {
178
+ throw new Error(`bad predicate quote: ${input}`);
179
+ }
180
+ pos.value++;
181
+ const end = input.indexOf(quote, pos.value);
182
+ if (end === -1) {
183
+ throw new Error(`bad predicate quote: ${input}`);
184
+ }
185
+ const value = input.slice(pos.value, end);
186
+ pos.value = end + 1;
187
+ return value;
188
+ }
189
+
190
+ function parsePredBlocks(input: string, pos: { value: number }): Pred[] {
191
+ const preds: Pred[] = [];
192
+ while (pos.value < input.length && input[pos.value] === "[") {
193
+ const start = pos.value + 1;
194
+ let depth = 1;
195
+ let quote: string | null = null;
196
+ let i = start;
197
+ while (i < input.length && depth) {
198
+ const c = input[i];
199
+ if (quote !== null) {
200
+ if (c === quote) {
201
+ quote = null;
202
+ }
203
+ } else if (c === "'" || c === '"') {
204
+ quote = c as string;
205
+ } else if (c === "[") {
206
+ depth++;
207
+ } else if (c === "]") {
208
+ depth--;
209
+ }
210
+ i++;
211
+ }
212
+ const inner = input.slice(start, i - 1);
213
+ preds.push(parsePredExpr(inner));
214
+ pos.value = i;
215
+ }
216
+ return preds;
217
+ }
218
+
219
+ function parseParenAtom(
220
+ input: string,
221
+ pos: { value: number },
222
+ parseOr: () => Pred,
223
+ ): Pred {
224
+ pos.value++;
225
+ const inner = parseOr();
226
+ expectChar(input, pos, ")", "paren");
227
+ return inner;
228
+ }
229
+
230
+ function parseClassContainsAtom(input: string, pos: { value: number }): Pred {
231
+ pos.value += "contains(@class,".length;
232
+ const value = readQuoted(input, pos);
233
+ expectChar(input, pos, ")", "contains");
234
+ return { op: "class-contains", value };
235
+ }
236
+
237
+ function parseAttrAtom(input: string, pos: { value: number }): Pred {
238
+ pos.value++;
239
+ const name = readWord(input, pos);
240
+ skipWhitespace(input, pos);
241
+ if (input[pos.value] === "=") {
242
+ pos.value++;
243
+ const value = readQuoted(input, pos);
244
+ return { op: "attr-eq", name, value };
245
+ }
246
+ return { op: "has-attr", name };
247
+ }
248
+
249
+ function parseAtom(
250
+ input: string,
251
+ pos: { value: number },
252
+ parseOr: () => Pred,
253
+ ): Pred {
254
+ skipWhitespace(input, pos);
255
+ if (input[pos.value] === "(") {
256
+ return parseParenAtom(input, pos, parseOr);
257
+ }
258
+ if (input.startsWith("position()=last()", pos.value)) {
259
+ pos.value += "position()=last()".length;
260
+ return { op: "last" };
261
+ }
262
+ if (input.startsWith("contains(@class,", pos.value)) {
263
+ return parseClassContainsAtom(input, pos);
264
+ }
265
+ if (input[pos.value] === "@") {
266
+ return parseAttrAtom(input, pos);
267
+ }
268
+ if (input.startsWith(".//", pos.value)) {
269
+ pos.value += 3;
270
+ const name = readWord(input, pos);
271
+ return { op: "desc", tag: name };
272
+ }
273
+ const name = readWord(input, pos);
274
+ const preds = parsePredBlocks(input, pos);
275
+ return { op: "child", tag: name, preds };
276
+ }
277
+
278
+ function parsePredExpr(input: string): Pred {
279
+ const pos = { value: 0 };
280
+ const atom = (): Pred => parseAtom(input, pos, parseOr);
281
+ const parseAnd = (): Pred => {
282
+ let left = atom();
283
+ while (true) {
284
+ while (
285
+ pos.value < input.length &&
286
+ /\s/.test(input[pos.value] as string)
287
+ ) {
288
+ pos.value++;
289
+ }
290
+ if (
291
+ input.startsWith("and", pos.value) &&
292
+ !/\w/.test(input[pos.value + 3] ?? "")
293
+ ) {
294
+ pos.value += 3;
295
+ left = { op: "and", a: left, b: atom() };
296
+ } else {
297
+ return left;
298
+ }
299
+ }
300
+ };
301
+ const parseOr = (): Pred => {
302
+ let left = parseAnd();
303
+ while (true) {
304
+ while (
305
+ pos.value < input.length &&
306
+ /\s/.test(input[pos.value] as string)
307
+ ) {
308
+ pos.value++;
309
+ }
310
+ if (
311
+ input.startsWith("or", pos.value) &&
312
+ !/\w/.test(input[pos.value + 2] ?? "")
313
+ ) {
314
+ pos.value += 2;
315
+ left = { op: "or", a: left, b: parseAnd() };
316
+ } else {
317
+ return left;
318
+ }
319
+ }
320
+ };
321
+ return parseOr();
322
+ }
323
+
324
+ const NODE_NAME_RE = /^[A-Za-z][A-Za-z0-9_-]*/;
325
+ const ATTR_NAME_RE = /^[A-Za-z0-9_-]+/;
326
+
327
+ function parsePathStart(expr: string): {
328
+ index: number;
329
+ axis: "child" | "descendant";
330
+ } {
331
+ if (expr.startsWith("//")) {
332
+ return { index: 2, axis: "descendant" };
333
+ }
334
+ if (expr.startsWith("./")) {
335
+ return expr[2] === "/"
336
+ ? { index: 3, axis: "descendant" }
337
+ : { index: 2, axis: "child" };
338
+ }
339
+ return { index: 0, axis: "child" };
340
+ }
341
+
342
+ function readAttrStep(
343
+ expr: string,
344
+ index: number,
345
+ ): { step: XStep; next: number } {
346
+ const m = ATTR_NAME_RE.exec(expr.slice(index));
347
+ return {
348
+ step: { kind: "attr", name: m ? m[0] : "" },
349
+ next: m ? index + m[0].length : index,
350
+ };
351
+ }
352
+
353
+ function parseSlashToken(
354
+ expr: string,
355
+ i: number,
356
+ ): { i: number; axis: "child" | "descendant" } {
357
+ const double = expr[i + 1] === "/";
358
+ return {
359
+ i: double ? i + 2 : i + 1,
360
+ axis: double ? "descendant" : "child",
361
+ };
362
+ }
363
+
364
+ function parseAttrToken(
365
+ expr: string,
366
+ i: number,
367
+ steps: XStep[],
368
+ ): { i: number } {
369
+ const attr = readAttrStep(expr, i + 1);
370
+ steps.push(attr.step);
371
+ return { i: attr.next };
372
+ }
373
+
374
+ function parseTextToken(steps: XStep[]): { i: number } {
375
+ steps.push({ kind: "text" });
376
+ return { i: "text()".length };
377
+ }
378
+
379
+ function parseNameToken(
380
+ expr: string,
381
+ i: number,
382
+ axis: "child" | "descendant",
383
+ steps: XStep[],
384
+ ): { i: number } | null {
385
+ const m = NODE_NAME_RE.exec(expr.slice(i));
386
+ if (!m) {
387
+ return null;
388
+ }
389
+ const pos = { value: i + m[0].length };
390
+ const preds = parsePredBlocks(expr, pos);
391
+ steps.push({ kind: "node", axis, name: m[0], preds });
392
+ return { i: pos.value };
393
+ }
394
+
395
+ function parseNextToken(
396
+ expr: string,
397
+ i: number,
398
+ axis: "child" | "descendant",
399
+ steps: XStep[],
400
+ ): { i: number; axis?: "child" | "descendant" } | null {
401
+ const ch = expr[i];
402
+ if (ch === "/") {
403
+ return parseSlashToken(expr, i);
404
+ }
405
+ if (ch === ".") {
406
+ return { i: i + 1 };
407
+ }
408
+ if (ch === "@") {
409
+ return parseAttrToken(expr, i, steps);
410
+ }
411
+ if (expr.startsWith("text()", i)) {
412
+ const text = parseTextToken(steps);
413
+ return { i: i + text.i };
414
+ }
415
+ return parseNameToken(expr, i, axis, steps);
416
+ }
417
+
418
+ function parsePath(expr: string): XStep[] {
419
+ const steps: XStep[] = [];
420
+ const start = parsePathStart(expr);
421
+ let i = start.index;
422
+ let axis = start.axis;
423
+ while (i < expr.length) {
424
+ const tok = parseNextToken(expr, i, axis, steps);
425
+ if (!tok) {
426
+ break;
427
+ }
428
+ i = tok.i;
429
+ if (tok.axis !== undefined) {
430
+ axis = tok.axis;
431
+ }
432
+ }
433
+ return steps;
434
+ }
435
+
436
+ function hasDescendantWithTag(node: DomNode, tag: string): boolean {
437
+ const stack: DomNode[] = [...node.children];
438
+ while (stack.length) {
439
+ // biome-ignore lint/style/noNonNullAssertion: stack non-empty (length checked)
440
+ const cur = stack.pop()!;
441
+ if (cur.tag === tag) {
442
+ return true;
443
+ }
444
+ for (let i = cur.children.length - 1; i >= 0; i--) {
445
+ // biome-ignore lint/style/noNonNullAssertion: i < cur.children.length
446
+ stack.push(cur.children[i]!);
447
+ }
448
+ }
449
+ return false;
450
+ }
451
+
452
+ function collectDescendants(el: DomNode, out: DomNode[]): void {
453
+ for (const child of el.children) {
454
+ out.push(child);
455
+ collectDescendants(child, out);
456
+ }
457
+ }
458
+
459
+ function matchesPred(
460
+ pred: Pred,
461
+ el: DomNode,
462
+ index: number,
463
+ total: number,
464
+ ): boolean {
465
+ switch (pred.op) {
466
+ case "or": {
467
+ return (
468
+ matchesPred(pred.a, el, index, total) ||
469
+ matchesPred(pred.b, el, index, total)
470
+ );
471
+ }
472
+ case "and": {
473
+ return (
474
+ matchesPred(pred.a, el, index, total) &&
475
+ matchesPred(pred.b, el, index, total)
476
+ );
477
+ }
478
+ case "last": {
479
+ return index === total - 1;
480
+ }
481
+ case "class-contains": {
482
+ // biome-ignore lint/complexity/useLiteralKeys: attrs is Record<string,string>
483
+ return (el.attrs["class"] ?? "").includes(pred.value);
484
+ }
485
+ case "attr-eq": {
486
+ return el.attrs[pred.name] === pred.value;
487
+ }
488
+ case "has-attr": {
489
+ return pred.name in el.attrs;
490
+ }
491
+ case "desc": {
492
+ return el.tag === pred.tag || hasDescendantWithTag(el, pred.tag);
493
+ }
494
+ case "child": {
495
+ return el.children.some(
496
+ (c) =>
497
+ c.tag === pred.tag &&
498
+ pred.preds.every((p) => matchesPred(p, c, 0, 1)),
499
+ );
500
+ }
501
+ }
502
+ }
503
+
504
+ function descendantsOf(el: DomNode): DomNode[] {
505
+ const out: DomNode[] = [];
506
+ collectDescendants(el, out);
507
+ return out;
508
+ }
509
+
510
+ function collectCandidates(
511
+ step: Extract<XStep, { kind: "node" }>,
512
+ nodes: DomNode[],
513
+ ): DomNode[] {
514
+ const candidates: DomNode[] = [];
515
+ for (const node of nodes) {
516
+ const source = step.axis === "child" ? node.children : descendantsOf(node);
517
+ for (const child of source) {
518
+ if (!step.name || child.tag === step.name) {
519
+ candidates.push(child);
520
+ }
521
+ }
522
+ }
523
+ return candidates;
524
+ }
525
+
526
+ function dedupeNodes(nodes: DomNode[]): DomNode[] {
527
+ const seen = new Set<DomNode>();
528
+ const deduped: DomNode[] = [];
529
+ for (const node of nodes) {
530
+ if (!seen.has(node)) {
531
+ seen.add(node);
532
+ deduped.push(node);
533
+ }
534
+ }
535
+ return deduped;
536
+ }
537
+
538
+ function applyStep(
539
+ step: Extract<XStep, { kind: "node" }>,
540
+ nodes: DomNode[],
541
+ ): DomNode[] {
542
+ const deduped = dedupeNodes(collectCandidates(step, nodes));
543
+ const total = deduped.length;
544
+ return deduped.filter((el, index) =>
545
+ step.preds.every((p) => matchesPred(p, el, index, total)),
546
+ );
547
+ }
548
+
549
+ export function xpathText(expr: string, node: DomNode): string[] {
550
+ const steps = parsePath(expr);
551
+ let nodes: DomNode[] = [node];
552
+ for (const step of steps) {
553
+ if (step.kind === "text") {
554
+ const out: string[] = [];
555
+ for (const n of nodes) {
556
+ out.push(...n.textNodes);
557
+ }
558
+ return out;
559
+ }
560
+ if (step.kind === "attr") {
561
+ return nodes.map((n) => n.attrs[step.name] ?? "");
562
+ }
563
+ nodes = applyStep(step, nodes);
564
+ }
565
+ return [];
566
+ }
567
+
568
+ export function xpathNodes(expr: string, root: DomNode): DomNode[] {
569
+ const steps = parsePath(expr);
570
+ let nodes: DomNode[] = [root];
571
+ for (const step of steps) {
572
+ if (step.kind !== "node") {
573
+ break;
574
+ }
575
+ nodes = applyStep(step, nodes);
576
+ }
577
+ return nodes;
578
+ }
579
+
580
+ export function extractResults(
581
+ html: string,
582
+ itemsXpath: string,
583
+ elementsXpath: { title: string; href: string; body: string },
584
+ ): SearchResult[] {
585
+ const root = buildDom(html);
586
+ const items = xpathNodes(itemsXpath, root);
587
+ const results: SearchResult[] = [];
588
+ for (const item of items) {
589
+ const result: SearchResult = { title: "", href: "", body: "" };
590
+ const entries = [
591
+ ["title", elementsXpath.title],
592
+ ["href", elementsXpath.href],
593
+ ["body", elementsXpath.body],
594
+ ] as const;
595
+ for (const [key, value] of entries) {
596
+ const data = xpathText(value, item)
597
+ .join("")
598
+ .trim()
599
+ .split(/\s+/)
600
+ .join(" ");
601
+ if (!data) {
602
+ continue;
603
+ }
604
+ result[key] = key === "href" ? normalizeUrl(data) : normalizeText(data);
605
+ }
606
+ results.push(result);
607
+ }
608
+ return results;
609
+ }
610
+
611
+ // ── Errors ──────────────────────────────────────────────────────────────────
612
+
613
+ export class EmptySweepError extends Error {
614
+ constructor() {
615
+ super("No results found");
616
+ this.name = "EmptySweepError";
617
+ }
618
+ }
619
+
620
+ export class SearchTimeoutError extends Error {
621
+ constructor() {
622
+ super("timed out");
623
+ this.name = "SearchTimeoutError";
624
+ }
625
+ }
626
+
627
+ export class SearchCancelled extends Error {
628
+ constructor() {
629
+ super("cancelled");
630
+ this.name = "SearchCancelled";
631
+ }
632
+ }
633
+
634
+ // ── Aggregator + Ranker (port of engines.ts) ────────────────────────────────
635
+
636
+ export class ResultsAggregator {
637
+ private readonly cache = new Map<string, SearchResult>();
638
+ private readonly counter = new Map<string, number>();
639
+
640
+ get size(): number {
641
+ return this.cache.size;
642
+ }
643
+
644
+ append(item: SearchResult): void {
645
+ const key = item.href;
646
+ if (!key) {
647
+ return;
648
+ }
649
+ const existing = this.cache.get(key);
650
+ if (!existing || item.body.length > existing.body.length) {
651
+ this.cache.set(key, item);
652
+ }
653
+ this.counter.set(key, (this.counter.get(key) ?? 0) + 1);
654
+ }
655
+
656
+ extend(items: SearchResult[]): void {
657
+ for (const item of items) {
658
+ this.append(item);
659
+ }
660
+ }
661
+
662
+ extractDicts(): SearchResult[] {
663
+ return (
664
+ [...this.counter.entries()]
665
+ .sort((a, b) => b[1] - a[1])
666
+ // biome-ignore lint/style/noNonNullAssertion: explanation
667
+ .map(([key]) => this.cache.get(key)!)
668
+ );
669
+ }
670
+ }
671
+
672
+ function extractTokens(query: string): Set<string> {
673
+ return new Set(
674
+ query
675
+ .toLowerCase()
676
+ .split(/\W+/u)
677
+ .filter((t) => t.length >= 3),
678
+ );
679
+ }
680
+
681
+ function hasAnyToken(text: string, tokens: Set<string>): boolean {
682
+ const lower = text.toLowerCase();
683
+ for (const token of tokens) {
684
+ if (lower.includes(token)) {
685
+ return true;
686
+ }
687
+ }
688
+ return false;
689
+ }
690
+
691
+ export function rankResults(
692
+ docs: SearchResult[],
693
+ query: string,
694
+ ): SearchResult[] {
695
+ const tokens = extractTokens(query);
696
+ const wiki: SearchResult[] = [];
697
+ const both: SearchResult[] = [];
698
+ const titleOnly: SearchResult[] = [];
699
+ const bodyOnly: SearchResult[] = [];
700
+ const neither: SearchResult[] = [];
701
+ for (const doc of docs) {
702
+ if (doc.title.includes("Category:") && doc.title.includes("Wikimedia")) {
703
+ continue;
704
+ }
705
+ if (doc.href.includes("wikipedia.org")) {
706
+ wiki.push(doc);
707
+ continue;
708
+ }
709
+ const hitTitle = hasAnyToken(doc.title, tokens);
710
+ const hitBody = hasAnyToken(doc.body, tokens);
711
+ if (hitTitle && hitBody) {
712
+ both.push(doc);
713
+ } else if (hitTitle) {
714
+ titleOnly.push(doc);
715
+ } else if (hitBody) {
716
+ bodyOnly.push(doc);
717
+ } else {
718
+ neither.push(doc);
719
+ }
720
+ }
721
+ return [...wiki, ...both, ...titleOnly, ...bodyOnly, ...neither];
722
+ }
723
+
724
+ // ── Engines ─────────────────────────────────────────────────────────────────
725
+
726
+ const USER_AGENTS = [
727
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
728
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
729
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
730
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0",
731
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0",
732
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15",
733
+ ];
734
+
735
+ function randomUserAgent(): string {
736
+ // biome-ignore lint/style/noNonNullAssertion: explanation
737
+ return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)]!;
738
+ }
739
+
740
+ function googleUserAgent(): string {
741
+ const devices: [string, string, number, number][] = [
742
+ ["5.0", "SM-G900P Build/LRX21T", 39, 60],
743
+ ["6.0", "Nexus 5 Build/MRA58N", 39, 60],
744
+ ["8.0", "Pixel 2 Build/OPD3.170816.012", 39, 60],
745
+ ];
746
+ const [androidVer, device, chromeMin, chromeMax] =
747
+ // biome-ignore lint/style/noNonNullAssertion: explanation
748
+ devices[Math.floor(Math.random() * devices.length)]!;
749
+ const chromeMajor =
750
+ chromeMin + Math.floor(Math.random() * (chromeMax - chromeMin + 1));
751
+ const chromeBuild = 1000 + Math.floor(Math.random() * 9000);
752
+ const chromePatch = 1000 + Math.floor(Math.random() * 1000);
753
+ return (
754
+ `Mozilla/5.0 (Linux; Android ${androidVer}; ${device}) ` +
755
+ `AppleWebKit/537.36 (KHTML, like Gecko) ` +
756
+ `Chrome/${chromeMajor}.0.${chromeBuild}.${chromePatch} Mobile Safari/537.36`
757
+ );
758
+ }
759
+
760
+ function tokenUrlSafe(byteLength: number): string {
761
+ return randomBytes(byteLength).toString("base64url");
762
+ }
763
+
764
+ function unquotePlus(value: string): string {
765
+ try {
766
+ return decodeURIComponent(value.replace(/\+/g, "%20"));
767
+ } catch {
768
+ return value.replace(/\+/g, " ");
769
+ }
770
+ }
771
+
772
+ function yahooExtractUrl(raw: string): string {
773
+ const afterRu = raw.split("/RU=", 2)[1] ?? "";
774
+ const t = afterRu.split("/RK=", 1)[0]?.split("/RS=", 1)[0] ?? "";
775
+ return unquotePlus(t);
776
+ }
777
+
778
+ export interface EngineContext {
779
+ region: string;
780
+ safesearch: string;
781
+ }
782
+
783
+ export interface Engine {
784
+ name: string;
785
+ provider: string;
786
+ priority?: number;
787
+ search(
788
+ query: string,
789
+ ctx: EngineContext,
790
+ timeoutMs: number,
791
+ signal?: AbortSignal,
792
+ ): Promise<SearchResult[] | null>;
793
+ }
794
+
795
+ async function httpGet(
796
+ url: string,
797
+ params: Record<string, string>,
798
+ options: {
799
+ headers?: Record<string, string>;
800
+ cookies?: Record<string, string>;
801
+ timeoutMs: number;
802
+ signal?: AbortSignal;
803
+ },
804
+ ): Promise<string | null> {
805
+ const target = new URL(url);
806
+ for (const [key, value] of Object.entries(params)) {
807
+ target.searchParams.set(key, value);
808
+ }
809
+ return httpFetch(target.toString(), options);
810
+ }
811
+
812
+ async function httpPost(
813
+ url: string,
814
+ data: Record<string, string>,
815
+ options: {
816
+ headers?: Record<string, string>;
817
+ cookies?: Record<string, string>;
818
+ timeoutMs: number;
819
+ signal?: AbortSignal;
820
+ },
821
+ ): Promise<string | null> {
822
+ const headers: Record<string, string> = {
823
+ "Content-Type": "application/x-www-form-urlencoded",
824
+ ...(options.headers ?? {}),
825
+ };
826
+ return httpFetch(url, {
827
+ ...options,
828
+ headers,
829
+ method: "POST",
830
+ body: new URLSearchParams(data).toString(),
831
+ });
832
+ }
833
+
834
+ async function httpFetch(
835
+ url: string,
836
+ options: {
837
+ method?: string;
838
+ body?: string;
839
+ headers?: Record<string, string>;
840
+ cookies?: Record<string, string>;
841
+ timeoutMs: number;
842
+ signal?: AbortSignal;
843
+ },
844
+ ): Promise<string | null> {
845
+ const headers: Record<string, string> = {
846
+ "User-Agent": options.headers?.["User-Agent"] ?? randomUserAgent(),
847
+ Accept: "*/*",
848
+ ...options.headers,
849
+ };
850
+ const cookie = options.cookies
851
+ ? Object.entries(options.cookies)
852
+ .map(([key, value]) => `${key}=${value}`)
853
+ .join("; ")
854
+ : null;
855
+ if (cookie) {
856
+ headers.Cookie = cookie;
857
+ }
858
+ const signals: AbortSignal[] = [AbortSignal.timeout(options.timeoutMs)];
859
+ if (options.signal) {
860
+ signals.push(options.signal);
861
+ }
862
+ let response: Response;
863
+ try {
864
+ response = await fetch(url, {
865
+ method: options.method ?? "GET",
866
+ headers,
867
+ body: options.method === "POST" ? options.body : undefined,
868
+ signal: AbortSignal.any(signals),
869
+ });
870
+ } catch (err) {
871
+ if (err instanceof DOMException && err.name === "TimeoutError") {
872
+ throw new Error("timed out");
873
+ }
874
+ throw err;
875
+ }
876
+ if (response.status !== 200) {
877
+ return null;
878
+ }
879
+ return response.text();
880
+ }
881
+
882
+ const DUCKDUCKGO: Engine = {
883
+ name: "duckduckgo",
884
+ provider: "bing",
885
+ async search(query, ctx, timeoutMs, signal) {
886
+ const html = await httpPost(
887
+ "https://html.duckduckgo.com/html/",
888
+ { q: query, b: "", l: ctx.region },
889
+ { headers: { "User-Agent": randomUserAgent() }, timeoutMs, signal },
890
+ );
891
+ if (!html) {
892
+ return null;
893
+ }
894
+ // Current markup nests the anchor inside h2.result__title and serves the
895
+ // snippet via a.result__snippet; result__url would otherwise pollute body.
896
+ const results = extractResults(html, "//div[contains(@class, 'body')]", {
897
+ title: ".//h2//text()",
898
+ href: ".//h2/a/@href",
899
+ body: ".//a[contains(@class,'result__snippet')]//text()",
900
+ });
901
+ return results.filter(
902
+ (r) => !r.href.startsWith("https://duckduckgo.com/y.js?"),
903
+ );
904
+ },
905
+ };
906
+
907
+ const BRAVE: Engine = {
908
+ name: "brave",
909
+ provider: "brave",
910
+ async search(query, ctx, timeoutMs, signal) {
911
+ // biome-ignore lint/style/noNonNullAssertion: explanation
912
+ const country = ctx.region.toLowerCase().split("-")[0]!;
913
+ const cookies: Record<string, string> = {
914
+ [country]: country,
915
+ useLocation: "0",
916
+ };
917
+ if (ctx.safesearch !== "moderate") {
918
+ cookies.safesearch = ctx.safesearch === "on" ? "strict" : "off";
919
+ }
920
+ const html = await httpGet(
921
+ "https://search.brave.com/search",
922
+ { q: query, source: "web" },
923
+ { cookies, timeoutMs, signal },
924
+ );
925
+ if (!html) {
926
+ return null;
927
+ }
928
+ return extractResults(html, "//div[@data-type='web']", {
929
+ title:
930
+ ".//div[(contains(@class,'title') or contains(@class,'sitename-container')) and position()=last()]//text()",
931
+ href: ".//a[div[contains(@class, 'title')]]/@href",
932
+ body: ".//div[contains(@class, 'snippet')]//div[contains(@class, 'content')]//text()",
933
+ });
934
+ },
935
+ };
936
+
937
+ const GOOGLE: Engine = {
938
+ name: "google",
939
+ provider: "google",
940
+ async search(query, ctx, timeoutMs, signal) {
941
+ const parts = ctx.region.toLowerCase().split("-");
942
+ const country = parts[0] ?? "us";
943
+ const lang = parts[1] ?? "en";
944
+ const safesearchBase: Record<string, string> = {
945
+ on: "active",
946
+ moderate: "active",
947
+ off: "off",
948
+ };
949
+ const html = await httpGet(
950
+ "https://www.google.com/search",
951
+ {
952
+ q: query,
953
+ safe: safesearchBase[ctx.safesearch.toLowerCase()] ?? "active",
954
+ start: "0",
955
+ hl: `${lang}-${country.toUpperCase()}`,
956
+ lr: `lang_${lang}`,
957
+ cr: `country${country.toUpperCase()}`,
958
+ },
959
+ {
960
+ headers: { "User-Agent": googleUserAgent() },
961
+ cookies: { CONSENT: "YES+" },
962
+ timeoutMs,
963
+ signal,
964
+ },
965
+ );
966
+ if (!html) {
967
+ return null;
968
+ }
969
+ const results = extractResults(html, "//div[@data-hveid][.//h3]", {
970
+ title: ".//h3//text()",
971
+ href: ".//a[.//h3]/@href",
972
+ body: "./div/div[last()]//text()",
973
+ });
974
+ return results
975
+ .map((r) => {
976
+ if (r.href.startsWith("/url?q=")) {
977
+ r.href = r.href.split("?q=")[1]?.split("&")[0] ?? r.href;
978
+ }
979
+ return r;
980
+ })
981
+ .filter((r) => r.title && r.href.startsWith("http"));
982
+ },
983
+ };
984
+
985
+ const MOJEEK: Engine = {
986
+ name: "mojeek",
987
+ provider: "mojeek",
988
+ async search(query, ctx, timeoutMs, signal) {
989
+ const parts = ctx.region.toLowerCase().split("-");
990
+ const country = parts[0] ?? "us";
991
+ const lang = parts[1] ?? "en";
992
+ const params: Record<string, string> = { q: query };
993
+ if (ctx.safesearch === "on") {
994
+ params.safe = "1";
995
+ }
996
+ const html = await httpGet("https://www.mojeek.com/search", params, {
997
+ cookies: { arc: country, lb: lang },
998
+ timeoutMs,
999
+ signal,
1000
+ });
1001
+ if (!html) {
1002
+ return null;
1003
+ }
1004
+ return extractResults(html, "//ul[contains(@class, 'results')]/li", {
1005
+ title: ".//h2//text()",
1006
+ href: ".//h2/a/@href",
1007
+ body: ".//p[@class='s']//text()",
1008
+ });
1009
+ },
1010
+ };
1011
+
1012
+ const YAHOO: Engine = {
1013
+ name: "yahoo",
1014
+ provider: "bing",
1015
+ async search(query, _ctx, timeoutMs, signal) {
1016
+ const ylt = tokenUrlSafe(18);
1017
+ const ylu = tokenUrlSafe(35);
1018
+ const html = await httpGet(
1019
+ `https://search.yahoo.com/search;_ylt=${ylt};_ylu=${ylu}`,
1020
+ { p: query },
1021
+ { timeoutMs, signal },
1022
+ );
1023
+ if (!html) {
1024
+ return null;
1025
+ }
1026
+ const results = extractResults(html, "//div[contains(@class, 'relsrch')]", {
1027
+ title: ".//div[contains(@class, 'Title')]//h3//text()",
1028
+ href: ".//div[contains(@class, 'Title')]//a/@href",
1029
+ body: ".//div[contains(@class, 'Text')]//text()",
1030
+ });
1031
+ // Unwrap /RU= redirects BEFORE the ad filter: ads hide behind yahoo
1032
+ // redirects whose decoded target is a bing adclick URL.
1033
+ return results
1034
+ .map((r) => {
1035
+ if (r.href.includes("/RU=")) {
1036
+ r.href = yahooExtractUrl(r.href);
1037
+ }
1038
+ return r;
1039
+ })
1040
+ .filter((r) => !r.href.startsWith("https://www.bing.com/aclick?"));
1041
+ },
1042
+ };
1043
+
1044
+ const YANDEX: Engine = {
1045
+ name: "yandex",
1046
+ provider: "yandex",
1047
+ async search(query, _ctx, timeoutMs, signal) {
1048
+ const searchid = String(1_000_000 + Math.floor(Math.random() * 9_000_000));
1049
+ const html = await httpGet(
1050
+ "https://yandex.com/search/site/",
1051
+ { text: query, web: "1", searchid },
1052
+ { timeoutMs, signal },
1053
+ );
1054
+ if (!html) {
1055
+ return null;
1056
+ }
1057
+ return extractResults(html, "//li[contains(@class, 'serp-item')]", {
1058
+ title: ".//h3//text()",
1059
+ href: ".//h3//a/@href",
1060
+ body: ".//div[contains(@class, 'text')]//text()",
1061
+ });
1062
+ },
1063
+ };
1064
+
1065
+ const WIKIPEDIA: Engine = {
1066
+ name: "wikipedia",
1067
+ provider: "wikipedia",
1068
+ priority: 2,
1069
+ async search(query, ctx, timeoutMs, signal) {
1070
+ const lang = ctx.region.toLowerCase().split("-")[1] ?? "en";
1071
+ const encoded = encodeURIComponent(query);
1072
+ const opensearchUrl = `https://${lang}.wikipedia.org/w/api.php?action=opensearch&profile=fuzzy&limit=1&search=${encoded}`;
1073
+ const opensearch = await httpGet(opensearchUrl, {}, { timeoutMs, signal });
1074
+ if (!opensearch) {
1075
+ return null;
1076
+ }
1077
+ let data: unknown;
1078
+ try {
1079
+ data = JSON.parse(opensearch);
1080
+ } catch {
1081
+ return null;
1082
+ }
1083
+ const payload = data as [string, string[], string[], string[]];
1084
+ if (!payload[1]?.length) {
1085
+ return [];
1086
+ }
1087
+ // biome-ignore lint/style/noNonNullAssertion: explanation
1088
+ const title = payload[1][0]!;
1089
+ // biome-ignore lint/style/noNonNullAssertion: explanation
1090
+ const href = payload[3][0]!;
1091
+ let body = "";
1092
+ const extractUrl =
1093
+ `https://${lang}.wikipedia.org/w/api.php?action=query&format=json&prop=extracts` +
1094
+ `&titles=${encodeURIComponent(title)}&explaintext=0&exintro=0&redirects=1`;
1095
+ const extract = await httpGet(extractUrl, {}, { timeoutMs, signal });
1096
+ if (extract) {
1097
+ try {
1098
+ const pageData = JSON.parse(extract) as {
1099
+ query: { pages: Record<string, { extract?: string }> };
1100
+ };
1101
+ const pages = Object.values(pageData.query.pages);
1102
+ if (pages.length) {
1103
+ body = pages[0]?.extract ?? "";
1104
+ }
1105
+ } catch {
1106
+ body = "";
1107
+ }
1108
+ }
1109
+ if (body.includes("may refer to:")) {
1110
+ return [];
1111
+ }
1112
+ return [
1113
+ {
1114
+ title: normalizeText(title),
1115
+ href: normalizeUrl(href),
1116
+ body: normalizeText(body),
1117
+ },
1118
+ ];
1119
+ },
1120
+ };
1121
+
1122
+ export const TEXT_ENGINES: Engine[] = [
1123
+ DUCKDUCKGO,
1124
+ BRAVE,
1125
+ GOOGLE,
1126
+ MOJEEK,
1127
+ YAHOO,
1128
+ YANDEX,
1129
+ WIKIPEDIA,
1130
+ ];
1131
+
1132
+ export function shuffleEnginesWithPriority(engines: Engine[]): Engine[] {
1133
+ const shuffled = [...engines];
1134
+ for (let i = shuffled.length - 1; i > 0; i--) {
1135
+ const j = Math.floor(Math.random() * (i + 1));
1136
+ // biome-ignore lint/style/noNonNullAssertion: indices in bounds
1137
+ [shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!] as [
1138
+ Engine,
1139
+ Engine,
1140
+ ];
1141
+ }
1142
+ const wikipedia = shuffled.find((e) => e.priority === 2);
1143
+ const rest = shuffled.filter((e) => e.priority !== 2);
1144
+ return wikipedia ? [wikipedia, ...rest] : shuffled;
1145
+ }
1146
+
1147
+ export function shuffledEngines(): Engine[] {
1148
+ return shuffleEnginesWithPriority(TEXT_ENGINES);
1149
+ }
1150
+
1151
+ export function formatSearchResults(results: SearchResult[]): string {
1152
+ return sharedFormatSearchResults(results);
1153
+ }
1154
+
1155
+ export interface UnslothConfig {
1156
+ timeoutMs: number;
1157
+ overallTimeoutMs: number;
1158
+ region: string;
1159
+ safesearch: "on" | "moderate" | "off";
1160
+ engines: import("../../config").UnslothEngineId[];
1161
+ }
1162
+
1163
+ export function collectSearchError(
1164
+ err: unknown,
1165
+ signal?: AbortSignal,
1166
+ ): { shouldThrow: boolean; error: Error } | null {
1167
+ if (err instanceof DOMException && err.name === "AbortError") {
1168
+ return { shouldThrow: true, error: err };
1169
+ }
1170
+ if (err instanceof Error && err.message.includes("timed out")) {
1171
+ return { shouldThrow: true, error: new Error("Request timed out") };
1172
+ }
1173
+ if (err instanceof Error && signal?.aborted) {
1174
+ return { shouldThrow: true, error: err };
1175
+ }
1176
+ if (signal?.aborted) {
1177
+ return {
1178
+ shouldThrow: true,
1179
+ error: new DOMException("The operation was aborted.", "AbortError"),
1180
+ };
1181
+ }
1182
+ return err ? { shouldThrow: false, error: err as Error } : null;
1183
+ }
1184
+
1185
+ interface ScheduleResult {
1186
+ ranked: SearchResult[];
1187
+ err: unknown;
1188
+ }
1189
+
1190
+ async function scheduleEngines(
1191
+ ordered: Engine[],
1192
+ query: string,
1193
+ ctx: EngineContext,
1194
+ timeoutMs: number,
1195
+ signal: AbortSignal | undefined,
1196
+ maxResults: number,
1197
+ ): Promise<ScheduleResult> {
1198
+ const seenProviders = new Set<string>();
1199
+ const aggregator = new ResultsAggregator();
1200
+ let err: unknown = null;
1201
+ const uniqueProviders = new Set(ordered.map((e) => e.provider)).size;
1202
+ const maxWorkers = Math.min(uniqueProviders, Math.ceil(maxResults / 10) + 1);
1203
+ let i = 0;
1204
+ let pending: Promise<void>[] = [];
1205
+ const run = async (engine: Engine) => {
1206
+ try {
1207
+ const results = await engine.search(query, ctx, timeoutMs, signal);
1208
+ if (results?.length) {
1209
+ aggregator.extend(results);
1210
+ seenProviders.add(engine.provider);
1211
+ }
1212
+ } catch (e) {
1213
+ err = e;
1214
+ }
1215
+ };
1216
+ while (i < ordered.length) {
1217
+ if (aggregator.size >= maxResults) {
1218
+ break;
1219
+ }
1220
+ // biome-ignore lint/style/noNonNullAssertion: i < ordered.length
1221
+ const engine = ordered[i++]!;
1222
+ if (seenProviders.has(engine.provider)) {
1223
+ continue;
1224
+ }
1225
+ pending.push(run(engine));
1226
+ if (pending.length >= maxWorkers) {
1227
+ await Promise.allSettled(pending);
1228
+ pending = [];
1229
+ }
1230
+ }
1231
+ if (pending.length) {
1232
+ await Promise.allSettled(pending);
1233
+ }
1234
+ return { ranked: rankResults(aggregator.extractDicts(), query), err };
1235
+ }
1236
+
1237
+ async function runUnslothSearch(
1238
+ filtered: Engine[],
1239
+ args: import("../types").SearchArgs,
1240
+ config: UnslothConfig,
1241
+ perEngineSignal: AbortSignal,
1242
+ ): Promise<string | undefined> {
1243
+ const ctx: EngineContext = {
1244
+ region: config.region,
1245
+ safesearch: config.safesearch,
1246
+ };
1247
+ const ordered = shuffleEnginesWithPriority(filtered);
1248
+ const maxResults = args.numResults ?? 8;
1249
+ const { ranked, err } = await scheduleEngines(
1250
+ ordered,
1251
+ args.query,
1252
+ ctx,
1253
+ config.timeoutMs,
1254
+ perEngineSignal,
1255
+ maxResults,
1256
+ );
1257
+ if (ranked.length) {
1258
+ return formatSearchResults(ranked.slice(0, maxResults));
1259
+ }
1260
+ const collected = collectSearchError(
1261
+ err,
1262
+ perEngineSignal as unknown as AbortSignal,
1263
+ );
1264
+ if (collected?.shouldThrow) {
1265
+ throw collected.error;
1266
+ }
1267
+ if (perEngineSignal.aborted) {
1268
+ throw new DOMException("The operation was aborted.", "AbortError");
1269
+ }
1270
+ return undefined;
1271
+ }
1272
+
1273
+ export function createUnslothProvider(
1274
+ config: UnslothConfig,
1275
+ ): import("../types").SearchProvider {
1276
+ const { overallTimeoutMs, engines } = config;
1277
+ return {
1278
+ name: "unsloth",
1279
+ usageNotes:
1280
+ "\n - Results are fetched directly from 7 engines (duckduckgo, brave, google, mojeek, yahoo, yandex, wikipedia) with provider-deduplication and frequency ranking — no API key or Docker required",
1281
+ async search(
1282
+ args: import("../types").SearchArgs,
1283
+ signal?: AbortSignal,
1284
+ ): Promise<string | undefined> {
1285
+ if (signal?.aborted) {
1286
+ throw new Error("Request aborted");
1287
+ }
1288
+ const filtered = TEXT_ENGINES.filter((e) =>
1289
+ (engines as string[]).includes(e.name),
1290
+ );
1291
+ if (filtered.length === 0) {
1292
+ return undefined;
1293
+ }
1294
+ const controller = new AbortController();
1295
+ const timeoutId = setTimeout(() => controller.abort(), overallTimeoutMs);
1296
+ const onAbort = () => controller.abort();
1297
+ if (signal) {
1298
+ signal.addEventListener("abort", onAbort, { once: true });
1299
+ }
1300
+ const perEngineSignal = signal
1301
+ ? AbortSignal.any([controller.signal, signal])
1302
+ : controller.signal;
1303
+ try {
1304
+ return await runUnslothSearch(filtered, args, config, perEngineSignal);
1305
+ } catch (error) {
1306
+ if (error instanceof DOMException && error.name === "AbortError") {
1307
+ throw error;
1308
+ }
1309
+ if (controller.signal.aborted && !signal?.aborted) {
1310
+ throw new Error("Request timed out");
1311
+ }
1312
+ throw error;
1313
+ } finally {
1314
+ clearTimeout(timeoutId);
1315
+ if (signal) {
1316
+ signal.removeEventListener("abort", onAbort);
1317
+ }
1318
+ }
1319
+ },
1320
+ };
1321
+ }
1322
+
1323
+ export async function autoTextSearch(
1324
+ query: string,
1325
+ maxResults: number,
1326
+ timeoutMs: number,
1327
+ signal?: AbortSignal,
1328
+ engines: Engine[] = shuffledEngines(),
1329
+ ): Promise<SearchResult[]> {
1330
+ const ctx: EngineContext = { region: "us-en", safesearch: "moderate" };
1331
+ const { ranked, err } = await scheduleEngines(
1332
+ engines,
1333
+ query,
1334
+ ctx,
1335
+ timeoutMs,
1336
+ signal,
1337
+ maxResults,
1338
+ );
1339
+ if (ranked.length) {
1340
+ return ranked.slice(0, maxResults);
1341
+ }
1342
+ if (err instanceof Error && err.message.includes("timed out")) {
1343
+ throw new SearchTimeoutError();
1344
+ }
1345
+ throw new EmptySweepError();
1346
+ }