pi-unsloth-webtools 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/engines.ts ADDED
@@ -0,0 +1,855 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { decodeHtmlEntities, feedHtml } from "./html-to-md.ts";
3
+ import type { AttrDict } from "./html-to-md.ts";
4
+ export class EmptySweepError extends Error {
5
+ constructor() {
6
+ super("No results found");
7
+ }
8
+ }
9
+
10
+ export class SearchTimeoutError extends Error {
11
+ constructor() {
12
+ super("timed out");
13
+ }
14
+ }
15
+
16
+ export class SearchCancelled extends Error {
17
+ constructor() {
18
+ super("cancelled");
19
+ }
20
+ }
21
+
22
+
23
+ export interface SearchResult {
24
+ title: string;
25
+ href: string;
26
+ body: string;
27
+ }
28
+
29
+ const STRIP_TAGS_RE = /<.*?>/g;
30
+
31
+ export function normalizeText(raw: string): string {
32
+ if (!raw) return "";
33
+ let text = raw.replace(STRIP_TAGS_RE, "");
34
+ text = decodeHtmlEntities(text);
35
+ text = text.normalize("NFC");
36
+ text = text.replace(/[\p{Cc}\p{Cf}\p{Co}\p{Cs}\p{Cn}]/gu, "");
37
+ return text.trim().split(/\s+/).join(" ");
38
+ }
39
+
40
+ export function normalizeUrl(url: string): string {
41
+ if (!url) return "";
42
+ try {
43
+ return decodeURIComponent(url).replace(/ /g, "+");
44
+ } catch {
45
+ return url.replace(/ /g, "+");
46
+ }
47
+ }
48
+
49
+ export interface DomNode {
50
+ tag: string;
51
+ attrs: Record<string, string>;
52
+ children: DomNode[];
53
+ textNodes: string[];
54
+ }
55
+
56
+ export function buildDom(html: string): DomNode {
57
+ const root: DomNode = { tag: "#root", attrs: {}, children: [], textNodes: [] };
58
+ const stack: DomNode[] = [root];
59
+ const pushText = (text: string) => {
60
+ for (const el of stack) el.textNodes.push(text);
61
+ };
62
+ feedHtml(html, {
63
+ handleStartTag(name: string, attrs: AttrDict) {
64
+ const el: DomNode = {
65
+ tag: name,
66
+ attrs: Object.fromEntries(
67
+ Object.entries(attrs).map(([key, value]) => [key, value ?? ""]),
68
+ ),
69
+ children: [],
70
+ textNodes: [],
71
+ };
72
+ stack[stack.length - 1].children.push(el);
73
+ stack.push(el);
74
+ },
75
+ handleStartEndTag() {},
76
+ handleEndTag(name: string) {
77
+ for (let i = stack.length - 1; i >= 1; i--) {
78
+ if (stack[i].tag === name) {
79
+ stack.length = i;
80
+ return;
81
+ }
82
+ }
83
+ },
84
+ handleData(text: string) {
85
+ pushText(text);
86
+ },
87
+ handleEntityRef(name: string) {
88
+ pushText(decodeHtmlEntities(`&${name};`));
89
+ },
90
+ handleCharRef(name: string) {
91
+ pushText(decodeHtmlEntities(`&#${name};`));
92
+ },
93
+ });
94
+ return root;
95
+ }
96
+
97
+ type Pred =
98
+ | { op: "or"; a: Pred; b: Pred }
99
+ | { op: "and"; a: Pred; b: Pred }
100
+ | { op: "last" }
101
+ | { op: "class-contains"; value: string }
102
+ | { op: "attr-eq"; name: string; value: string }
103
+ | { op: "has-attr"; name: string }
104
+ | { op: "desc"; tag: string }
105
+ | { op: "child"; tag: string; preds: Pred[] };
106
+
107
+ interface XStep {
108
+ axis: "descendant" | "child";
109
+ name?: string;
110
+ preds: Pred[];
111
+ terminal?: "text" | string;
112
+ }
113
+
114
+ function parsePredExpr(input: string): Pred {
115
+ let pos = 0;
116
+ const ws = () => {
117
+ while (pos < input.length && /\s/.test(input[pos])) pos++;
118
+ };
119
+ const word = () => {
120
+ ws();
121
+ const m = /^[A-Za-z][A-Za-z0-9_-]*/.exec(input.slice(pos));
122
+ if (!m) throw new Error(`bad predicate: ${input}`);
123
+ pos += m[0].length;
124
+ return m[0];
125
+ };
126
+ const quoted = () => {
127
+ ws();
128
+ const quote = input[pos];
129
+ if (quote !== "'" && quote !== '"') throw new Error(`bad predicate quote: ${input}`);
130
+ pos++;
131
+ const end = input.indexOf(quote, pos);
132
+ if (end === -1) throw new Error(`bad predicate quote: ${input}`);
133
+ const value = input.slice(pos, end);
134
+ pos = end + 1;
135
+ return value;
136
+ };
137
+ const atom = (): Pred => {
138
+ ws();
139
+ if (input[pos] === "(") {
140
+ pos++;
141
+ const inner = parseOr();
142
+ ws();
143
+ if (input[pos] !== ")") throw new Error(`bad predicate paren: ${input}`);
144
+ pos++;
145
+ return inner;
146
+ }
147
+ if (input.startsWith("position()=last()", pos)) {
148
+ pos += "position()=last()".length;
149
+ return { op: "last" };
150
+ }
151
+ if (input.startsWith("contains(@class,", pos)) {
152
+ pos += "contains(@class,".length;
153
+ const value = quoted();
154
+ ws();
155
+ if (input[pos] !== ")") throw new Error(`bad predicate contains: ${input}`);
156
+ pos++;
157
+ return { op: "class-contains", value };
158
+ }
159
+ if (input[pos] === "@") {
160
+ pos++;
161
+ const name = word();
162
+ ws();
163
+ if (input[pos] === "=") {
164
+ pos++;
165
+ const value = quoted();
166
+ return { op: "attr-eq", name, value };
167
+ }
168
+ return { op: "has-attr", name };
169
+ }
170
+ if (input.startsWith(".//", pos)) {
171
+ pos += 3;
172
+ const name = word();
173
+ return { op: "desc", tag: name };
174
+ }
175
+ const name = word();
176
+ const preds = parsePredBlocks();
177
+ return { op: "child", tag: name, preds };
178
+ };
179
+ const parsePredBlocks = (): Pred[] => {
180
+ const preds: Pred[] = [];
181
+ while (pos < input.length && input[pos] === "[") {
182
+ const start = pos + 1;
183
+ let depth = 1;
184
+ let quote: string | null = null;
185
+ let i = start;
186
+ while (i < input.length && depth) {
187
+ const c = input[i];
188
+ if (quote !== null) {
189
+ if (c === quote) quote = null;
190
+ } else if (c === "'" || c === '"') {
191
+ quote = c;
192
+ } else if (c === "[") {
193
+ depth++;
194
+ } else if (c === "]") {
195
+ depth--;
196
+ }
197
+ i++;
198
+ }
199
+ const inner = input.slice(start, i - 1);
200
+ preds.push(parsePredExpr(inner));
201
+ pos = i;
202
+ }
203
+ return preds;
204
+ };
205
+ const parseAnd = (): Pred => {
206
+ let left = atom();
207
+ while (true) {
208
+ ws();
209
+ if (input.startsWith("and", pos) && !/[A-Za-z0-9_]/.test(input[pos + 3] ?? "")) {
210
+ pos += 3;
211
+ left = { op: "and", a: left, b: atom() };
212
+ } else {
213
+ return left;
214
+ }
215
+ }
216
+ };
217
+ const parseOr = (): Pred => {
218
+ let left = parseAnd();
219
+ while (true) {
220
+ ws();
221
+ if (input.startsWith("or", pos) && !/[A-Za-z0-9_]/.test(input[pos + 2] ?? "")) {
222
+ pos += 2;
223
+ left = { op: "or", a: left, b: parseAnd() };
224
+ } else {
225
+ return left;
226
+ }
227
+ }
228
+ };
229
+ return parseOr();
230
+ }
231
+
232
+ function parsePath(expr: string): XStep[] {
233
+ const steps: XStep[] = [];
234
+ let i = 0;
235
+ let axis: "child" | "descendant" = "child";
236
+ if (expr.startsWith("//")) {
237
+ axis = "descendant";
238
+ i = 2;
239
+ } else if (expr.startsWith("./")) {
240
+ i = 2;
241
+ if (expr[i] === "/") {
242
+ axis = "descendant";
243
+ i++;
244
+ }
245
+ }
246
+ while (i < expr.length) {
247
+ if (expr[i] === "/") {
248
+ if (expr[i + 1] === "/") {
249
+ axis = "descendant";
250
+ i += 2;
251
+ } else {
252
+ axis = "child";
253
+ i++;
254
+ }
255
+ continue;
256
+ }
257
+ if (expr[i] === ".") {
258
+ i++;
259
+ continue;
260
+ }
261
+ if (expr[i] === "@") {
262
+ i++;
263
+ const m = /^[A-Za-z0-9_-]+/.exec(expr.slice(i));
264
+ steps.push({ axis, preds: [], terminal: m ? m[0] : "" });
265
+ i += m ? m[0].length : 0;
266
+ continue;
267
+ }
268
+ if (expr.startsWith("text()", i)) {
269
+ steps.push({ axis, preds: [], terminal: "text" });
270
+ i += 6;
271
+ continue;
272
+ }
273
+ const m = /^[A-Za-z][A-Za-z0-9_-]*/.exec(expr.slice(i));
274
+ if (!m) break;
275
+ const name = m[0];
276
+ i += m[0].length;
277
+ const preds: Pred[] = [];
278
+ while (i < expr.length && expr[i] === "[") {
279
+ const start = i + 1;
280
+ let depth = 1;
281
+ let quote: string | null = null;
282
+ let j = start;
283
+ while (j < expr.length && depth) {
284
+ const c = expr[j];
285
+ if (quote !== null) {
286
+ if (c === quote) quote = null;
287
+ } else if (c === "'" || c === '"') {
288
+ quote = c;
289
+ } else if (c === "[") {
290
+ depth++;
291
+ } else if (c === "]") {
292
+ depth--;
293
+ }
294
+ j++;
295
+ }
296
+ preds.push(parsePredExpr(expr.slice(start, j - 1)));
297
+ i = j;
298
+ }
299
+ steps.push({ axis, name, preds });
300
+ }
301
+ return steps;
302
+ }
303
+
304
+ function descendantsOf(el: DomNode): DomNode[] {
305
+ const out: DomNode[] = [];
306
+ const walk = (node: DomNode) => {
307
+ for (const child of node.children) {
308
+ out.push(child);
309
+ walk(child);
310
+ }
311
+ };
312
+ walk(el);
313
+ return out;
314
+ }
315
+
316
+ function matchesPred(pred: Pred, el: DomNode, index: number, total: number): boolean {
317
+ switch (pred.op) {
318
+ case "or":
319
+ return matchesPred(pred.a, el, index, total) || matchesPred(pred.b, el, index, total);
320
+ case "and":
321
+ return matchesPred(pred.a, el, index, total) && matchesPred(pred.b, el, index, total);
322
+ case "last":
323
+ return index === total - 1;
324
+ case "class-contains":
325
+ return (el.attrs["class"] ?? "").includes(pred.value);
326
+ case "attr-eq":
327
+ return el.attrs[pred.name] === pred.value;
328
+ case "has-attr":
329
+ return pred.name in el.attrs;
330
+ case "desc":
331
+ return el.tag === pred.tag || descendantsOf(el).some((d) => d.tag === pred.tag);
332
+ case "child":
333
+ return el.children.some(
334
+ (c) => c.tag === pred.tag && pred.preds.every((p) => matchesPred(p, c, 0, 1)),
335
+ );
336
+ }
337
+ }
338
+
339
+ function applyStep(step: XStep, nodes: DomNode[]): DomNode[] {
340
+ const candidates: DomNode[] = [];
341
+ for (const node of nodes) {
342
+ const list = step.axis === "child" ? node.children : descendantsOf(node);
343
+ for (const c of list) {
344
+ if (step.name && c.tag !== step.name) continue;
345
+ candidates.push(c);
346
+ }
347
+ }
348
+ const deduped: DomNode[] = [];
349
+ const seen = new Set<DomNode>();
350
+ for (const c of candidates) {
351
+ if (!seen.has(c)) {
352
+ seen.add(c);
353
+ deduped.push(c);
354
+ }
355
+ }
356
+ const total = deduped.length;
357
+ return deduped.filter((el, index) => step.preds.every((p) => matchesPred(p, el, index, total)));
358
+ }
359
+
360
+ export function xpathText(expr: string, node: DomNode): string[] {
361
+ const steps = parsePath(expr);
362
+ let nodes: DomNode[] = [node];
363
+ for (const step of steps) {
364
+ if (step.terminal === "text") {
365
+ const out: string[] = [];
366
+ for (const n of nodes) out.push(...n.textNodes);
367
+ return out;
368
+ }
369
+ if (step.terminal !== undefined) {
370
+ return nodes.map((n) => n.attrs[step.terminal as string] ?? "");
371
+ }
372
+ nodes = applyStep(step, nodes);
373
+ }
374
+ return [];
375
+ }
376
+
377
+ export function xpathNodes(expr: string, root: DomNode): DomNode[] {
378
+ const steps = parsePath(expr);
379
+ let nodes: DomNode[] = [root];
380
+ for (const step of steps) {
381
+ if (step.terminal) break;
382
+ nodes = applyStep(step, nodes);
383
+ }
384
+ return nodes;
385
+ }
386
+
387
+ export function extractResults(
388
+ html: string,
389
+ itemsXpath: string,
390
+ elementsXpath: { title: string; href: string; body: string },
391
+ ): SearchResult[] {
392
+ const root = buildDom(html);
393
+ const items = xpathNodes(itemsXpath, root);
394
+ const results: SearchResult[] = [];
395
+ for (const item of items) {
396
+ const result: SearchResult = { title: "", href: "", body: "" };
397
+ const entries = [
398
+ ["title", elementsXpath.title],
399
+ ["href", elementsXpath.href],
400
+ ["body", elementsXpath.body],
401
+ ] as const;
402
+ for (const [key, value] of entries) {
403
+ const data = xpathText(value, item).join("").trim().split(/\s+/).join(" ");
404
+ if (!data) continue;
405
+ result[key] = key === "href" ? normalizeUrl(data) : normalizeText(data);
406
+ }
407
+ results.push(result);
408
+ }
409
+ return results;
410
+ }
411
+
412
+ const USER_AGENTS = [
413
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
414
+ "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",
415
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
416
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0",
417
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0",
418
+ "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",
419
+ ];
420
+
421
+ function randomUserAgent(): string {
422
+ return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
423
+ }
424
+
425
+ function googleUserAgent(): string {
426
+ const devices: [string, string, number, number][] = [
427
+ ["5.0", "SM-G900P Build/LRX21T", 39, 60],
428
+ ["6.0", "Nexus 5 Build/MRA58N", 39, 60],
429
+ ["8.0", "Pixel 2 Build/OPD3.170816.012", 39, 60],
430
+ ];
431
+ const [androidVer, device, chromeMin, chromeMax] = devices[Math.floor(Math.random() * devices.length)];
432
+ const chromeMajor = chromeMin + Math.floor(Math.random() * (chromeMax - chromeMin + 1));
433
+ const chromeBuild = 1000 + Math.floor(Math.random() * 9000);
434
+ const chromePatch = 1000 + Math.floor(Math.random() * 1000);
435
+ return (
436
+ `Mozilla/5.0 (Linux; Android ${androidVer}; ${device}) ` +
437
+ `AppleWebKit/537.36 (KHTML, like Gecko) ` +
438
+ `Chrome/${chromeMajor}.0.${chromeBuild}.${chromePatch} Mobile Safari/537.36` +
439
+ "NSTNWV"
440
+ );
441
+ }
442
+
443
+ function tokenUrlSafe(byteLength: number): string {
444
+ return randomBytes(byteLength).toString("base64url");
445
+ }
446
+
447
+ function unquotePlus(value: string): string {
448
+ try {
449
+ return decodeURIComponent(value.replace(/\+/g, "%20"));
450
+ } catch {
451
+ return value.replace(/\+/g, " ");
452
+ }
453
+ }
454
+
455
+ function yahooExtractUrl(raw: string): string {
456
+ const afterRu = raw.split("/RU=", 2)[1] ?? "";
457
+ const t = afterRu.split("/RK=", 1)[0].split("/RS=", 1)[0];
458
+ return unquotePlus(t);
459
+ }
460
+
461
+ interface EngineContext {
462
+ region: string;
463
+ safesearch: string;
464
+ }
465
+
466
+ export interface Engine {
467
+ name: string;
468
+ provider: string;
469
+ priority?: number;
470
+ search(
471
+ query: string,
472
+ ctx: EngineContext,
473
+ timeoutMs: number,
474
+ signal?: AbortSignal,
475
+ ): Promise<SearchResult[] | null>;
476
+ }
477
+
478
+ async function httpGet(
479
+ url: string,
480
+ params: Record<string, string>,
481
+ options: {
482
+ headers?: Record<string, string>;
483
+ cookies?: Record<string, string>;
484
+ timeoutMs: number;
485
+ signal?: AbortSignal;
486
+ },
487
+ ): Promise<string | null> {
488
+ const target = new URL(url);
489
+ for (const [key, value] of Object.entries(params)) target.searchParams.set(key, value);
490
+ return httpFetch(target.toString(), options);
491
+ }
492
+
493
+ async function httpPost(
494
+ url: string,
495
+ data: Record<string, string>,
496
+ options: {
497
+ headers?: Record<string, string>;
498
+ cookies?: Record<string, string>;
499
+ timeoutMs: number;
500
+ signal?: AbortSignal;
501
+ },
502
+ ): Promise<string | null> {
503
+ return httpFetch(url, { ...options, method: "POST", body: new URLSearchParams(data).toString() });
504
+ }
505
+
506
+ async function httpFetch(
507
+ url: string,
508
+ options: {
509
+ method?: string;
510
+ body?: string;
511
+ headers?: Record<string, string>;
512
+ cookies?: Record<string, string>;
513
+ timeoutMs: number;
514
+ signal?: AbortSignal;
515
+ },
516
+ ): Promise<string | null> {
517
+ const headers: Record<string, string> = {
518
+ "User-Agent": options.headers?.["User-Agent"] ?? randomUserAgent(),
519
+ Accept: "*/*",
520
+ ...options.headers,
521
+ };
522
+ const cookie = options.cookies
523
+ ? Object.entries(options.cookies)
524
+ .map(([key, value]) => `${key}=${value}`)
525
+ .join("; ")
526
+ : null;
527
+ if (cookie) headers["Cookie"] = cookie;
528
+ const signals: AbortSignal[] = [AbortSignal.timeout(options.timeoutMs)];
529
+ if (options.signal) signals.push(options.signal);
530
+ let response: Response;
531
+ try {
532
+ response = await fetch(url, {
533
+ method: options.method ?? "GET",
534
+ headers,
535
+ body: options.method === "POST" ? options.body : undefined,
536
+ signal: AbortSignal.any(signals),
537
+ });
538
+ } catch (err) {
539
+ if (err instanceof DOMException && err.name === "TimeoutError") {
540
+ throw new Error("timed out");
541
+ }
542
+ throw err;
543
+ }
544
+ if (response.status !== 200) return null;
545
+ return response.text();
546
+ }
547
+
548
+ const DUCKDUCKGO: Engine = {
549
+ name: "duckduckgo",
550
+ provider: "bing",
551
+ async search(query, ctx, timeoutMs, signal) {
552
+ const html = await httpPost(
553
+ "https://html.duckduckgo.com/html/",
554
+ { q: query, b: "", l: ctx.region },
555
+ { headers: { "User-Agent": randomUserAgent() }, timeoutMs, signal },
556
+ );
557
+ if (!html) return null;
558
+ const results = extractResults(html, "//div[contains(@class, 'body')]", {
559
+ title: ".//h2//text()",
560
+ href: "./a/@href",
561
+ body: "./a//text()",
562
+ });
563
+ return results.filter((r) => !r.href.startsWith("https://duckduckgo.com/y.js?"));
564
+ },
565
+ };
566
+
567
+ const BRAVE: Engine = {
568
+ name: "brave",
569
+ provider: "brave",
570
+ async search(query, ctx, timeoutMs, signal) {
571
+ const country = ctx.region.toLowerCase().split("-")[0];
572
+ const cookies: Record<string, string> = { [country]: country, useLocation: "0" };
573
+ if (ctx.safesearch !== "moderate") {
574
+ cookies["safesearch"] = ctx.safesearch === "on" ? "strict" : "off";
575
+ }
576
+ const html = await httpGet(
577
+ "https://search.brave.com/search",
578
+ { q: query, source: "web" },
579
+ { cookies, timeoutMs, signal },
580
+ );
581
+ if (!html) return null;
582
+ return extractResults(html, "//div[@data-type='web']", {
583
+ title:
584
+ ".//div[(contains(@class,'title') or contains(@class,'sitename-container')) and position()=last()]//text()",
585
+ href: ".//a[div[contains(@class, 'title')]]/@href",
586
+ body: ".//div[contains(@class, 'snippet')]//div[contains(@class, 'content')]//text()",
587
+ });
588
+ },
589
+ };
590
+
591
+ const GOOGLE: Engine = {
592
+ name: "google",
593
+ provider: "google",
594
+ async search(query, ctx, timeoutMs, signal) {
595
+ const [country, lang] = ctx.region.split("-");
596
+ const safesearchBase: Record<string, string> = { on: "2", moderate: "1", off: "0" };
597
+ const html = await httpGet(
598
+ "https://www.google.com/search",
599
+ {
600
+ q: query,
601
+ filter: safesearchBase[ctx.safesearch.toLowerCase()] ?? "1",
602
+ start: "0",
603
+ hl: `${lang}-${country.toUpperCase()}`,
604
+ lr: `lang_${lang}`,
605
+ cr: `country${country.toUpperCase()}`,
606
+ },
607
+ {
608
+ headers: { "User-Agent": googleUserAgent() },
609
+ cookies: { CONSENT: "YES+" },
610
+ timeoutMs,
611
+ signal,
612
+ },
613
+ );
614
+ if (!html) return null;
615
+ const results = extractResults(html, "//div[@data-hveid][.//h3]", {
616
+ title: ".//h3//text()",
617
+ href: ".//a[.//h3]/@href",
618
+ body: "./div/div[last()]//text()",
619
+ });
620
+ return results
621
+ .map((r) => {
622
+ if (r.href.startsWith("/url?q=")) {
623
+ r.href = r.href.split("?q=")[1].split("&")[0];
624
+ }
625
+ return r;
626
+ })
627
+ .filter((r) => r.title && r.href.startsWith("http"));
628
+ },
629
+ };
630
+
631
+ const MOJEEK: Engine = {
632
+ name: "mojeek",
633
+ provider: "mojeek",
634
+ async search(query, ctx, timeoutMs, signal) {
635
+ const [country, lang] = ctx.region.toLowerCase().split("-");
636
+ const params: Record<string, string> = { q: query };
637
+ if (ctx.safesearch === "on") params["safe"] = "1";
638
+ const html = await httpGet(
639
+ "https://www.mojeek.com/search",
640
+ params,
641
+ { cookies: { arc: country, lb: lang }, timeoutMs, signal },
642
+ );
643
+ if (!html) return null;
644
+ return extractResults(html, "//ul[contains(@class, 'results')]/li", {
645
+ title: ".//h2//text()",
646
+ href: ".//h2/a/@href",
647
+ body: ".//p[@class='s']//text()",
648
+ });
649
+ },
650
+ };
651
+
652
+ const YAHOO: Engine = {
653
+ name: "yahoo",
654
+ provider: "bing",
655
+ async search(query, ctx, timeoutMs, signal) {
656
+ const ylt = tokenUrlSafe(18);
657
+ const ylu = tokenUrlSafe(35);
658
+ const html = await httpGet(
659
+ `https://search.yahoo.com/search;_ylt=${ylt};_ylu=${ylu}`,
660
+ { p: query },
661
+ { timeoutMs, signal },
662
+ );
663
+ if (!html) return null;
664
+ const results = extractResults(html, "//div[contains(@class, 'relsrch')]", {
665
+ title: ".//div[contains(@class, 'Title')]//h3//text()",
666
+ href: ".//div[contains(@class, 'Title')]//a/@href",
667
+ body: ".//div[contains(@class, 'Text')]//text()",
668
+ });
669
+ return results
670
+ .filter((r) => !r.href.startsWith("https://www.bing.com/aclick?"))
671
+ .map((r) => {
672
+ if (r.href.includes("/RU=")) r.href = yahooExtractUrl(r.href);
673
+ return r;
674
+ });
675
+ },
676
+ };
677
+
678
+ const YANDEX: Engine = {
679
+ name: "yandex",
680
+ provider: "yandex",
681
+ async search(query, ctx, timeoutMs, signal) {
682
+ const searchid = 1000000 + Math.floor(Math.random() * 9000000);
683
+ const html = await httpGet(
684
+ "https://yandex.com/search/site/",
685
+ { text: query, web: "1", searchid: String(searchid) },
686
+ { timeoutMs, signal },
687
+ );
688
+ if (!html) return null;
689
+ return extractResults(html, "//li[contains(@class, 'serp-item')]", {
690
+ title: ".//h3//text()",
691
+ href: ".//h3//a/@href",
692
+ body: ".//div[contains(@class, 'text')]//text()",
693
+ });
694
+ },
695
+ };
696
+
697
+ const WIKIPEDIA: Engine = {
698
+ name: "wikipedia",
699
+ provider: "wikipedia",
700
+ priority: 2,
701
+ async search(query, ctx, timeoutMs, signal) {
702
+ const lang = ctx.region.toLowerCase().split("-")[1] ?? "en";
703
+ const encoded = encodeURIComponent(query);
704
+ const opensearchUrl =
705
+ `https://${lang}.wikipedia.org/w/api.php?action=opensearch&profile=fuzzy&limit=1&search=${encoded}`;
706
+ const opensearch = await httpGet(opensearchUrl, {}, { timeoutMs, signal });
707
+ if (!opensearch) return null;
708
+ let data: unknown;
709
+ try {
710
+ data = JSON.parse(opensearch);
711
+ } catch {
712
+ return null;
713
+ }
714
+ const payload = data as [string, string[], string[], string[]];
715
+ if (!payload[1] || !payload[1].length) return [];
716
+ const title = payload[1][0];
717
+ const href = payload[3][0];
718
+ let body = "";
719
+ const extractUrl =
720
+ `https://${lang}.wikipedia.org/w/api.php?action=query&format=json&prop=extracts` +
721
+ `&titles=${encodeURIComponent(title)}&explaintext=0&exintro=0&redirects=1`;
722
+ const extract = await httpGet(extractUrl, {}, { timeoutMs, signal });
723
+ if (extract) {
724
+ try {
725
+ const pageData = JSON.parse(extract) as {
726
+ query: { pages: Record<string, { extract?: string }> };
727
+ };
728
+ const pages = Object.values(pageData.query.pages);
729
+ if (pages.length) body = pages[0].extract ?? "";
730
+ } catch {
731
+ body = "";
732
+ }
733
+ }
734
+ if (body.includes("may refer to:")) return [];
735
+ return [{ title: normalizeText(title), href: normalizeUrl(href), body: normalizeText(body) }];
736
+ },
737
+ };
738
+
739
+ const TEXT_ENGINES: Engine[] = [DUCKDUCKGO, BRAVE, GOOGLE, MOJEEK, YAHOO, YANDEX, WIKIPEDIA];
740
+
741
+ export class ResultsAggregator {
742
+ private cache = new Map<string, SearchResult>();
743
+ private counter = new Map<string, number>();
744
+
745
+ get size(): number {
746
+ return this.cache.size;
747
+ }
748
+
749
+ append(item: SearchResult): void {
750
+ const key = item.href;
751
+ const existing = this.cache.get(key);
752
+ if (!existing || item.body.length > existing.body.length) {
753
+ this.cache.set(key, item);
754
+ }
755
+ this.counter.set(key, (this.counter.get(key) ?? 0) + 1);
756
+ }
757
+
758
+ extend(items: SearchResult[]): void {
759
+ for (const item of items) this.append(item);
760
+ }
761
+
762
+ extractDicts(): SearchResult[] {
763
+ return [...this.counter.entries()]
764
+ .sort((a, b) => b[1] - a[1])
765
+ .map(([key]) => this.cache.get(key)!);
766
+ }
767
+ }
768
+
769
+ function extractTokens(query: string): Set<string> {
770
+ return new Set(query.toLowerCase().split(/\W+/u).filter((t) => t.length >= 3));
771
+ }
772
+
773
+ function hasAnyToken(text: string, tokens: Set<string>): boolean {
774
+ const lower = text.toLowerCase();
775
+ for (const token of tokens) {
776
+ if (lower.includes(token)) return true;
777
+ }
778
+ return false;
779
+ }
780
+
781
+ export function rankResults(docs: SearchResult[], query: string): SearchResult[] {
782
+ const tokens = extractTokens(query);
783
+ const wiki: SearchResult[] = [];
784
+ const both: SearchResult[] = [];
785
+ const titleOnly: SearchResult[] = [];
786
+ const bodyOnly: SearchResult[] = [];
787
+ const neither: SearchResult[] = [];
788
+ for (const doc of docs) {
789
+ if (doc.title.includes("Category:") && doc.title.includes("Wikimedia")) continue;
790
+ if (doc.href.includes("wikipedia.org")) {
791
+ wiki.push(doc);
792
+ continue;
793
+ }
794
+ const hitTitle = hasAnyToken(doc.title, tokens);
795
+ const hitBody = hasAnyToken(doc.body, tokens);
796
+ if (hitTitle && hitBody) both.push(doc);
797
+ else if (hitTitle) titleOnly.push(doc);
798
+ else if (hitBody) bodyOnly.push(doc);
799
+ else neither.push(doc);
800
+ }
801
+ return [...wiki, ...both, ...titleOnly, ...bodyOnly, ...neither];
802
+ }
803
+
804
+ function shuffledEngines(): Engine[] {
805
+ const shuffled = [...TEXT_ENGINES];
806
+ for (let i = shuffled.length - 1; i > 0; i--) {
807
+ const j = Math.floor(Math.random() * (i + 1));
808
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
809
+ }
810
+ const wikipedia = shuffled.find((e) => e.priority === 2);
811
+ const rest = shuffled.filter((e) => e.priority !== 2);
812
+ return wikipedia ? [wikipedia, ...rest] : shuffled;
813
+ }
814
+
815
+ export async function autoTextSearch(
816
+ query: string,
817
+ maxResults: number,
818
+ timeoutMs: number,
819
+ signal?: AbortSignal,
820
+ ): Promise<SearchResult[]> {
821
+ const engines = shuffledEngines();
822
+ const seenProviders = new Set<string>();
823
+ const aggregator = new ResultsAggregator();
824
+ const ctx: EngineContext = { region: "us-en", safesearch: "moderate" };
825
+ let err: unknown = null;
826
+ const uniqueProviders = new Set(engines.map((e) => e.provider)).size;
827
+ const maxWorkers = Math.min(uniqueProviders, Math.ceil(maxResults / 10) + 1);
828
+ let i = 0;
829
+ let pending: Promise<void>[] = [];
830
+ const run = async (engine: Engine) => {
831
+ try {
832
+ const results = await engine.search(query, ctx, timeoutMs, signal);
833
+ if (results && results.length) {
834
+ aggregator.extend(results);
835
+ seenProviders.add(engine.provider);
836
+ }
837
+ } catch (e) {
838
+ err = e;
839
+ }
840
+ };
841
+ while (i < engines.length) {
842
+ if (aggregator.size >= maxResults) break;
843
+ const engine = engines[i++];
844
+ if (seenProviders.has(engine.provider)) continue;
845
+ pending.push(run(engine));
846
+ if (pending.length >= maxWorkers || i >= maxWorkers) {
847
+ await Promise.allSettled(pending);
848
+ pending = [];
849
+ }
850
+ }
851
+ const results = rankResults(aggregator.extractDicts(), query);
852
+ if (results.length) return results.slice(0, maxResults);
853
+ if (err instanceof Error && err.message.includes("timed out")) throw new SearchTimeoutError();
854
+ throw new EmptySweepError();
855
+ }