pi-unsloth-webtools 0.9.0 → 0.10.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/README.md +64 -45
- package/engines.ts +254 -267
- package/index.ts +16 -2
- package/lightpanda.ts +23 -0
- package/package.json +2 -4
- package/scripts/install-lightpanda.sh +93 -5
- package/settings.ts +57 -0
- package/tls-fetch.ts +18 -10
- package/web-fetch.ts +2 -2
- package/web-search.ts +19 -2
package/engines.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
2
1
|
import { appendFile, chmod, mkdir } from "node:fs/promises";
|
|
3
2
|
import { dirname, isAbsolute, join } from "node:path";
|
|
4
3
|
import { collapseWhitespace, decodeHtmlEntities, feedHtml } from "./html-to-md.ts";
|
|
5
4
|
import type { AttrDict } from "./html-to-md.ts";
|
|
6
5
|
import { randomUserAgent } from "./user-agents.ts";
|
|
7
6
|
import { agentDir } from "./agent-dir.ts";
|
|
8
|
-
import {
|
|
7
|
+
import { impersonatedRequest, type FetchTransport, type TlsHopOptions, type TlsHopResponse } from "./tls-fetch.ts";
|
|
8
|
+
import { checkUrlAccess, isPublicIp, MAX_SIGNAL_TIMEOUT_MS, type WebsitePolicy } from "./web-access.ts";
|
|
9
|
+
import { socksProxyForUrl } from "./proxy.ts";
|
|
9
10
|
export class EmptySweepError extends Error {
|
|
10
11
|
constructor() {
|
|
11
12
|
super("No results found");
|
|
@@ -247,6 +248,13 @@ function parsePredExpr(input: string): Pred {
|
|
|
247
248
|
const name = word();
|
|
248
249
|
return { op: "desc", tag: name };
|
|
249
250
|
}
|
|
251
|
+
if (input.startsWith("./", pos)) {
|
|
252
|
+
pos += 2;
|
|
253
|
+
const name = word();
|
|
254
|
+
const { preds, next } = parsePredicateBlocks(input, pos);
|
|
255
|
+
pos = next;
|
|
256
|
+
return { op: "child", tag: name, preds };
|
|
257
|
+
}
|
|
250
258
|
const name = word();
|
|
251
259
|
const { preds, next } = parsePredicateBlocks(input, pos);
|
|
252
260
|
pos = next;
|
|
@@ -471,42 +479,18 @@ export function extractResults(
|
|
|
471
479
|
return results;
|
|
472
480
|
}
|
|
473
481
|
|
|
474
|
-
|
|
475
|
-
const devices: [string, string, number, number][] = [
|
|
476
|
-
["5.0", "SM-G900P Build/LRX21T", 39, 60],
|
|
477
|
-
["6.0", "Nexus 5 Build/MRA58N", 39, 60],
|
|
478
|
-
["8.0", "Pixel 2 Build/OPD3.170816.012", 39, 60],
|
|
479
|
-
];
|
|
480
|
-
const [androidVer, device, chromeMin, chromeMax] = devices[Math.floor(Math.random() * devices.length)];
|
|
481
|
-
const chromeMajor = chromeMin + Math.floor(Math.random() * (chromeMax - chromeMin + 1));
|
|
482
|
-
const chromeBuild = 1000 + Math.floor(Math.random() * 9000);
|
|
483
|
-
const chromePatch = 1000 + Math.floor(Math.random() * 1000);
|
|
484
|
-
return (
|
|
485
|
-
`Mozilla/5.0 (Linux; Android ${androidVer}; ${device}) ` +
|
|
486
|
-
`AppleWebKit/537.36 (KHTML, like Gecko) ` +
|
|
487
|
-
`Chrome/${chromeMajor}.0.${chromeBuild}.${chromePatch} Mobile Safari/537.36`
|
|
488
|
-
);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
function tokenUrlSafe(byteLength: number): string {
|
|
492
|
-
return randomBytes(byteLength).toString("base64url");
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
function unquotePlus(value: string): string {
|
|
496
|
-
try {
|
|
497
|
-
return decodeURIComponent(value.replace(/\+/g, "%20"));
|
|
498
|
-
} catch {
|
|
499
|
-
return value.replace(/\+/g, " ");
|
|
500
|
-
}
|
|
501
|
-
}
|
|
482
|
+
export type EngineImpersonation = (options: TlsHopOptions) => Promise<TlsHopResponse | null>;
|
|
502
483
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
484
|
+
export interface SearchEngineOptions {
|
|
485
|
+
transport?: FetchTransport;
|
|
486
|
+
policy?: WebsitePolicy | null;
|
|
487
|
+
impersonate?: EngineImpersonation;
|
|
488
|
+
engines?: string[];
|
|
489
|
+
engineWeights?: Record<string, number>;
|
|
490
|
+
maxPerHost?: number;
|
|
507
491
|
}
|
|
508
492
|
|
|
509
|
-
interface EngineContext {
|
|
493
|
+
export interface EngineContext extends SearchEngineOptions {
|
|
510
494
|
region: string;
|
|
511
495
|
safesearch: string;
|
|
512
496
|
}
|
|
@@ -514,7 +498,6 @@ interface EngineContext {
|
|
|
514
498
|
export interface Engine {
|
|
515
499
|
name: string;
|
|
516
500
|
provider: string;
|
|
517
|
-
priority?: number;
|
|
518
501
|
search(
|
|
519
502
|
query: string,
|
|
520
503
|
ctx: EngineContext,
|
|
@@ -528,6 +511,7 @@ interface HttpRequestOptions {
|
|
|
528
511
|
cookies?: Record<string, string>;
|
|
529
512
|
timeoutMs: number;
|
|
530
513
|
signal?: AbortSignal;
|
|
514
|
+
ctx?: EngineContext;
|
|
531
515
|
}
|
|
532
516
|
|
|
533
517
|
interface HttpOptions extends HttpRequestOptions {
|
|
@@ -555,6 +539,8 @@ async function httpPost(
|
|
|
555
539
|
|
|
556
540
|
const MAX_ENGINE_RESPONSE_BYTES = 5 * 1024 * 1024;
|
|
557
541
|
const ENGINE_RETRY_BACKOFF_MS = 250;
|
|
542
|
+
const MAX_ENGINE_HOPS = 5;
|
|
543
|
+
const DEFAULT_ENGINE_TRANSPORT: FetchTransport = "off";
|
|
558
544
|
|
|
559
545
|
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
560
546
|
|
|
@@ -578,10 +564,84 @@ async function readBodyCapped(response: Response): Promise<string | null> {
|
|
|
578
564
|
return new TextDecoder("utf-8").decode(Buffer.concat(chunks));
|
|
579
565
|
}
|
|
580
566
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
567
|
+
interface EngineHopResponse {
|
|
568
|
+
status: number;
|
|
569
|
+
location: string | null;
|
|
570
|
+
body: string | null;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
type EngineTransportKind = "direct" | "tls";
|
|
574
|
+
|
|
575
|
+
function engineTransportOrder(transport: FetchTransport, target: URL): EngineTransportKind[] {
|
|
576
|
+
if (transport === "off" || socksProxyForUrl(target) !== null) return ["direct"];
|
|
577
|
+
return transport === "direct-first" ? ["direct", "tls"] : ["tls", "direct"];
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function engineTargetAllowed(target: string, policy: WebsitePolicy | null): boolean {
|
|
581
|
+
const [allowed, , hostname] = checkUrlAccess(target, policy);
|
|
582
|
+
if (!allowed) return false;
|
|
583
|
+
const isLiteral = hostname.includes(":") || /^\d+\.\d+\.\d+\.\d+$/.test(hostname);
|
|
584
|
+
return !isLiteral || isPublicIp(hostname);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function classifyRequestError(
|
|
588
|
+
err: unknown,
|
|
589
|
+
caller: AbortSignal | undefined,
|
|
590
|
+
hopSignal: AbortSignal,
|
|
591
|
+
): Error | null {
|
|
592
|
+
if (caller?.aborted) return new SearchCancelled();
|
|
593
|
+
if (hopSignal.aborted) return new SearchTimeoutError();
|
|
594
|
+
if (err instanceof DOMException && err.name === "TimeoutError") return new SearchTimeoutError();
|
|
595
|
+
if (err instanceof DOMException && err.name === "AbortError") return new SearchTimeoutError();
|
|
596
|
+
if (err instanceof Error && err.message === "timed out") return new SearchTimeoutError();
|
|
597
|
+
if (err instanceof Error && err.message === "cancelled") return new SearchCancelled();
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function directEngineHop(
|
|
602
|
+
target: URL,
|
|
603
|
+
headers: Record<string, string>,
|
|
604
|
+
method: string,
|
|
605
|
+
body: string | undefined,
|
|
606
|
+
signal: AbortSignal,
|
|
607
|
+
): Promise<EngineHopResponse | null> {
|
|
608
|
+
const response = await fetch(target.toString(), { method, headers, body, signal, redirect: "manual" });
|
|
609
|
+
if (response.status === 200) {
|
|
610
|
+
const text = await readBodyCapped(response);
|
|
611
|
+
return text === null ? null : { status: 200, location: null, body: text };
|
|
612
|
+
}
|
|
613
|
+
try {
|
|
614
|
+
await response.body?.cancel();
|
|
615
|
+
} catch {}
|
|
616
|
+
return { status: response.status, location: response.headers.get("location"), body: null };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function tlsEngineHop(
|
|
620
|
+
impersonate: EngineImpersonation,
|
|
621
|
+
target: URL,
|
|
622
|
+
headers: Record<string, string>,
|
|
623
|
+
method: string,
|
|
624
|
+
body: string | undefined,
|
|
625
|
+
caller: AbortSignal | undefined,
|
|
626
|
+
timeoutMs: number,
|
|
627
|
+
): Promise<EngineHopResponse | null> {
|
|
628
|
+
return impersonate({
|
|
629
|
+
url: target,
|
|
630
|
+
timeoutMs,
|
|
631
|
+
signal: caller,
|
|
632
|
+
maxBytes: MAX_ENGINE_RESPONSE_BYTES,
|
|
633
|
+
maxPdfBytes: MAX_ENGINE_RESPONSE_BYTES,
|
|
634
|
+
method,
|
|
635
|
+
body,
|
|
636
|
+
extraHeaders: headers,
|
|
637
|
+
}).then((response) => {
|
|
638
|
+
if (response === null || response.truncated) return null;
|
|
639
|
+
return {
|
|
640
|
+
status: response.status,
|
|
641
|
+
location: response.headers.location ?? null,
|
|
642
|
+
body: response.status === 200 ? new TextDecoder("utf-8").decode(response.body) : null,
|
|
643
|
+
};
|
|
644
|
+
});
|
|
585
645
|
}
|
|
586
646
|
|
|
587
647
|
async function httpFetch(
|
|
@@ -601,32 +661,67 @@ async function httpFetch(
|
|
|
601
661
|
: null;
|
|
602
662
|
if (cookie) headers["Cookie"] = cookie;
|
|
603
663
|
const timeoutMs = Math.min(MAX_SIGNAL_TIMEOUT_MS, Math.max(1, options.timeoutMs));
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
throw
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
664
|
+
const deadline = Date.now() + timeoutMs;
|
|
665
|
+
const caller = options.signal;
|
|
666
|
+
const transport = options.ctx?.transport ?? DEFAULT_ENGINE_TRANSPORT;
|
|
667
|
+
const policy = options.ctx?.policy ?? null;
|
|
668
|
+
const impersonate = options.ctx?.impersonate ?? (transport === "off" ? null : impersonatedRequest);
|
|
669
|
+
let method = options.method ?? "GET";
|
|
670
|
+
let body = method === "POST" ? options.body : undefined;
|
|
671
|
+
let target = url;
|
|
672
|
+
for (let hop = 0; hop < MAX_ENGINE_HOPS; hop++) {
|
|
673
|
+
if (caller?.aborted) throw new SearchCancelled();
|
|
674
|
+
const remaining = deadline - Date.now();
|
|
675
|
+
if (remaining <= 0) throw new SearchTimeoutError();
|
|
676
|
+
const targetUrl = new URL(target);
|
|
677
|
+
const hopSignal = caller
|
|
678
|
+
? AbortSignal.any([caller, AbortSignal.timeout(remaining)])
|
|
679
|
+
: AbortSignal.timeout(remaining);
|
|
680
|
+
let response: EngineHopResponse | null = null;
|
|
681
|
+
let failure: unknown = null;
|
|
682
|
+
for (const kind of engineTransportOrder(transport, targetUrl)) {
|
|
683
|
+
try {
|
|
684
|
+
let attempt: EngineHopResponse | null = null;
|
|
685
|
+
if (kind === "tls") {
|
|
686
|
+
if (impersonate !== null) {
|
|
687
|
+
attempt = await tlsEngineHop(impersonate, targetUrl, headers, method, body, caller, remaining);
|
|
688
|
+
}
|
|
689
|
+
} else {
|
|
690
|
+
attempt = await directEngineHop(targetUrl, headers, method, body, hopSignal);
|
|
691
|
+
}
|
|
692
|
+
if (attempt === null) continue;
|
|
693
|
+
if (response === null || (attempt.status < 400 && response.status >= 400)) response = attempt;
|
|
694
|
+
if (attempt.status < 400) break;
|
|
695
|
+
} catch (err) {
|
|
696
|
+
const mapped = classifyRequestError(err, caller, hopSignal);
|
|
697
|
+
if (mapped) throw mapped;
|
|
698
|
+
failure = err;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
if (response === null) {
|
|
702
|
+
if (failure) throw failure;
|
|
621
703
|
return null;
|
|
622
704
|
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
705
|
+
if (response.status >= 300 && response.status < 400) {
|
|
706
|
+
if (!response.location) return null;
|
|
707
|
+
let next: string;
|
|
708
|
+
try {
|
|
709
|
+
next = new URL(response.location, target).toString();
|
|
710
|
+
} catch {
|
|
711
|
+
return null;
|
|
712
|
+
}
|
|
713
|
+
if (!engineTargetAllowed(next, policy)) return null;
|
|
714
|
+
if (response.status !== 307 && response.status !== 308) {
|
|
715
|
+
method = "GET";
|
|
716
|
+
body = undefined;
|
|
717
|
+
}
|
|
718
|
+
target = next;
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
if (response.status !== 200) return null;
|
|
722
|
+
return response.body ?? "";
|
|
629
723
|
}
|
|
724
|
+
return null;
|
|
630
725
|
}
|
|
631
726
|
|
|
632
727
|
const DUCKDUCKGO: Engine = {
|
|
@@ -636,7 +731,7 @@ const DUCKDUCKGO: Engine = {
|
|
|
636
731
|
const html = await httpPost(
|
|
637
732
|
"https://html.duckduckgo.com/html/",
|
|
638
733
|
{ q: query, b: "", l: ctx.region },
|
|
639
|
-
{ headers: { "User-Agent": randomUserAgent() }, timeoutMs, signal },
|
|
734
|
+
{ headers: { "User-Agent": randomUserAgent() }, timeoutMs, signal, ctx },
|
|
640
735
|
);
|
|
641
736
|
if (!html) return null;
|
|
642
737
|
const results = extractResults(html, "//div[contains(@class, 'body')]", {
|
|
@@ -648,126 +743,15 @@ const DUCKDUCKGO: Engine = {
|
|
|
648
743
|
},
|
|
649
744
|
};
|
|
650
745
|
|
|
651
|
-
const BRAVE: Engine = {
|
|
652
|
-
name: "brave",
|
|
653
|
-
provider: "brave",
|
|
654
|
-
async search(query, ctx, timeoutMs, signal) {
|
|
655
|
-
const country = ctx.region.toLowerCase().split("-")[0];
|
|
656
|
-
const cookies: Record<string, string> = { [country]: country, useLocation: "0" };
|
|
657
|
-
if (ctx.safesearch !== "moderate") {
|
|
658
|
-
cookies["safesearch"] = ctx.safesearch === "on" ? "strict" : "off";
|
|
659
|
-
}
|
|
660
|
-
const html = await httpGet(
|
|
661
|
-
"https://search.brave.com/search",
|
|
662
|
-
{ q: query, source: "web" },
|
|
663
|
-
{ cookies, timeoutMs, signal },
|
|
664
|
-
);
|
|
665
|
-
if (!html) return null;
|
|
666
|
-
return extractResults(html, "//div[@data-type='web']", {
|
|
667
|
-
title:
|
|
668
|
-
".//div[(contains(@class,'title') or contains(@class,'sitename-container')) and position()=last()]//text()",
|
|
669
|
-
href: ".//a[div[contains(@class, 'title')]]/@href",
|
|
670
|
-
body: ".//div[contains(@class, 'snippet')]//div[contains(@class, 'content')]//text()",
|
|
671
|
-
});
|
|
672
|
-
},
|
|
673
|
-
};
|
|
674
|
-
|
|
675
|
-
const GOOGLE: Engine = {
|
|
676
|
-
name: "google",
|
|
677
|
-
provider: "google",
|
|
678
|
-
async search(query, ctx, timeoutMs, signal) {
|
|
679
|
-
const [country, lang] = ctx.region.split("-");
|
|
680
|
-
const safesearchBase: Record<string, string> = { on: "2", moderate: "1", off: "0" };
|
|
681
|
-
const html = await httpGet(
|
|
682
|
-
"https://www.google.com/search",
|
|
683
|
-
{
|
|
684
|
-
q: query,
|
|
685
|
-
filter: safesearchBase[ctx.safesearch.toLowerCase()] ?? "1",
|
|
686
|
-
start: "0",
|
|
687
|
-
hl: `${lang}-${country.toUpperCase()}`,
|
|
688
|
-
lr: `lang_${lang}`,
|
|
689
|
-
cr: `country${country.toUpperCase()}`,
|
|
690
|
-
},
|
|
691
|
-
{
|
|
692
|
-
headers: { "User-Agent": googleUserAgent() },
|
|
693
|
-
cookies: { CONSENT: "YES+" },
|
|
694
|
-
timeoutMs,
|
|
695
|
-
signal,
|
|
696
|
-
},
|
|
697
|
-
);
|
|
698
|
-
if (!html) return null;
|
|
699
|
-
const results = extractResults(html, "//div[@data-hveid][.//h3]", {
|
|
700
|
-
title: ".//h3//text()",
|
|
701
|
-
href: ".//a[.//h3]/@href",
|
|
702
|
-
body: "./div/div[last()]//text()",
|
|
703
|
-
});
|
|
704
|
-
return results
|
|
705
|
-
.map((r) => {
|
|
706
|
-
if (r.href.startsWith("/url?q=")) {
|
|
707
|
-
r.href = r.href.split("?q=")[1].split("&")[0];
|
|
708
|
-
}
|
|
709
|
-
return r;
|
|
710
|
-
})
|
|
711
|
-
.filter((r) => r.title && r.href.startsWith("http"));
|
|
712
|
-
},
|
|
713
|
-
};
|
|
714
|
-
|
|
715
|
-
const MOJEEK: Engine = {
|
|
716
|
-
name: "mojeek",
|
|
717
|
-
provider: "mojeek",
|
|
718
|
-
async search(query, ctx, timeoutMs, signal) {
|
|
719
|
-
const [country, lang] = ctx.region.toLowerCase().split("-");
|
|
720
|
-
const params: Record<string, string> = { q: query };
|
|
721
|
-
if (ctx.safesearch === "on") params["safe"] = "1";
|
|
722
|
-
const html = await httpGet(
|
|
723
|
-
"https://www.mojeek.com/search",
|
|
724
|
-
params,
|
|
725
|
-
{ cookies: { arc: country, lb: lang }, timeoutMs, signal },
|
|
726
|
-
);
|
|
727
|
-
if (!html) return null;
|
|
728
|
-
return extractResults(html, "//ul[contains(@class, 'results')]/li", {
|
|
729
|
-
title: ".//h2//text()",
|
|
730
|
-
href: ".//h2/a/@href",
|
|
731
|
-
body: ".//p[@class='s']//text()",
|
|
732
|
-
});
|
|
733
|
-
},
|
|
734
|
-
};
|
|
735
|
-
|
|
736
|
-
const YAHOO: Engine = {
|
|
737
|
-
name: "yahoo",
|
|
738
|
-
provider: "bing",
|
|
739
|
-
async search(query, _ctx, timeoutMs, signal) {
|
|
740
|
-
const ylt = tokenUrlSafe(18);
|
|
741
|
-
const ylu = tokenUrlSafe(35);
|
|
742
|
-
const html = await httpGet(
|
|
743
|
-
`https://search.yahoo.com/search;_ylt=${ylt};_ylu=${ylu}`,
|
|
744
|
-
{ p: query },
|
|
745
|
-
{ timeoutMs, signal },
|
|
746
|
-
);
|
|
747
|
-
if (!html) return null;
|
|
748
|
-
const results = extractResults(html, "//div[contains(@class, 'relsrch')]", {
|
|
749
|
-
title: ".//div[contains(@class, 'Title')]//h3//text()",
|
|
750
|
-
href: ".//div[contains(@class, 'Title')]//a/@href",
|
|
751
|
-
body: ".//div[contains(@class, 'Text')]//text()",
|
|
752
|
-
});
|
|
753
|
-
return results
|
|
754
|
-
.filter((r) => !r.href.startsWith("https://www.bing.com/aclick?"))
|
|
755
|
-
.map((r) => {
|
|
756
|
-
if (r.href.includes("/RU=")) r.href = yahooExtractUrl(r.href);
|
|
757
|
-
return r;
|
|
758
|
-
});
|
|
759
|
-
},
|
|
760
|
-
};
|
|
761
|
-
|
|
762
746
|
const YANDEX: Engine = {
|
|
763
747
|
name: "yandex",
|
|
764
748
|
provider: "yandex",
|
|
765
|
-
async search(query,
|
|
749
|
+
async search(query, ctx, timeoutMs, signal) {
|
|
766
750
|
const searchid = 1000000 + Math.floor(Math.random() * 9000000);
|
|
767
751
|
const html = await httpGet(
|
|
768
752
|
"https://yandex.com/search/site/",
|
|
769
753
|
{ text: query, web: "1", searchid: String(searchid) },
|
|
770
|
-
{ timeoutMs, signal },
|
|
754
|
+
{ timeoutMs, signal, ctx },
|
|
771
755
|
);
|
|
772
756
|
if (!html) return null;
|
|
773
757
|
return extractResults(html, "//li[contains(@class, 'serp-item')]", {
|
|
@@ -778,60 +762,36 @@ const YANDEX: Engine = {
|
|
|
778
762
|
},
|
|
779
763
|
};
|
|
780
764
|
|
|
781
|
-
const
|
|
782
|
-
name: "
|
|
783
|
-
provider: "
|
|
784
|
-
priority: 2,
|
|
765
|
+
const START_PAGE: Engine = {
|
|
766
|
+
name: "startpage",
|
|
767
|
+
provider: "google",
|
|
785
768
|
async search(query, ctx, timeoutMs, signal) {
|
|
786
|
-
const
|
|
787
|
-
const
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
if (!
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
}
|
|
799
|
-
const payload = data as [string, string[], string[], string[]];
|
|
800
|
-
if (!payload[1] || !payload[1].length) return [];
|
|
801
|
-
const title = payload[1][0];
|
|
802
|
-
const href = payload[3][0];
|
|
803
|
-
let body = "";
|
|
804
|
-
const extractUrl =
|
|
805
|
-
`https://${lang}.wikipedia.org/w/api.php?action=query&format=json&prop=extracts` +
|
|
806
|
-
`&titles=${encodeURIComponent(title)}&explaintext=0&exintro=0&redirects=1`;
|
|
807
|
-
const extract = await httpGet(extractUrl, {}, { timeoutMs: Math.max(1, timeoutMs - (Date.now() - started)), signal });
|
|
808
|
-
if (extract) {
|
|
809
|
-
try {
|
|
810
|
-
const pageData = JSON.parse(extract) as {
|
|
811
|
-
query: { pages: Record<string, { extract?: string }> };
|
|
812
|
-
};
|
|
813
|
-
const pages = Object.values(pageData.query.pages);
|
|
814
|
-
if (pages.length) body = pages[0].extract ?? "";
|
|
815
|
-
} catch {
|
|
816
|
-
body = "";
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
if (body.includes("may refer to:")) return [];
|
|
820
|
-
return [{ title: normalizeText(title), href: normalizeUrl(href), body: normalizeText(body) }];
|
|
769
|
+
const [country, lang] = ctx.region.toLowerCase().split("-");
|
|
770
|
+
const html = await httpGet(
|
|
771
|
+
"https://www.startpage.com/sp/search",
|
|
772
|
+
{ query, qsr: `${lang}_${country.toUpperCase()}` },
|
|
773
|
+
{ headers: { Referer: "https://www.startpage.com/" }, timeoutMs, signal, ctx },
|
|
774
|
+
);
|
|
775
|
+
if (!html) return null;
|
|
776
|
+
return extractResults(html, "//div[contains(@class, 'result')][./a]", {
|
|
777
|
+
title: ".//h2//text()",
|
|
778
|
+
href: "./a/@href",
|
|
779
|
+
body: ".//p//text()",
|
|
780
|
+
});
|
|
821
781
|
},
|
|
822
782
|
};
|
|
823
783
|
|
|
824
|
-
export const TEXT_ENGINES: Engine[] = [DUCKDUCKGO,
|
|
784
|
+
export const TEXT_ENGINES: Engine[] = [DUCKDUCKGO, YANDEX, START_PAGE];
|
|
825
785
|
|
|
826
786
|
export class ResultsAggregator {
|
|
827
787
|
private cache = new Map<string, SearchResult>();
|
|
828
|
-
private
|
|
788
|
+
private scores = new Map<string, number>();
|
|
829
789
|
|
|
830
790
|
get size(): number {
|
|
831
791
|
return this.cache.size;
|
|
832
792
|
}
|
|
833
793
|
|
|
834
|
-
append(item: SearchResult): void {
|
|
794
|
+
append(item: SearchResult, weight = 1, rank = 1): void {
|
|
835
795
|
if (typeof item.href !== "string" || !item.href.trim()) return;
|
|
836
796
|
const key = canonicalizeHref(item.href);
|
|
837
797
|
if (!key) return;
|
|
@@ -839,54 +799,66 @@ export class ResultsAggregator {
|
|
|
839
799
|
if (!existing || item.body.length > existing.body.length) {
|
|
840
800
|
this.cache.set(key, { ...item, href: key });
|
|
841
801
|
}
|
|
842
|
-
this.
|
|
802
|
+
this.scores.set(key, (this.scores.get(key) ?? 0) + weight / (RRF_RANK_CONSTANT + rank));
|
|
843
803
|
}
|
|
844
804
|
|
|
845
|
-
extend(items: SearchResult[]): void {
|
|
846
|
-
|
|
805
|
+
extend(items: SearchResult[], weight = 1): void {
|
|
806
|
+
const seen = new Set<string>();
|
|
807
|
+
items.forEach((item, index) => {
|
|
808
|
+
const key = canonicalizeHref(item.href);
|
|
809
|
+
if (!key) return;
|
|
810
|
+
const first = !seen.has(key);
|
|
811
|
+
seen.add(key);
|
|
812
|
+
this.append(item, first ? weight : 0, index + 1);
|
|
813
|
+
});
|
|
847
814
|
}
|
|
848
815
|
|
|
849
|
-
|
|
850
|
-
return [...this.
|
|
851
|
-
.
|
|
852
|
-
.map(([key]) => this.
|
|
816
|
+
ranked(): SearchResult[] {
|
|
817
|
+
return [...this.cache.entries()]
|
|
818
|
+
.filter(([, doc]) => !isWikimediaCategory(doc))
|
|
819
|
+
.map(([key, doc]) => ({ doc, score: this.scores.get(key) ?? 0 }))
|
|
820
|
+
.sort((a, b) => b.score - a.score || a.doc.href.localeCompare(b.doc.href))
|
|
821
|
+
.map((entry) => entry.doc);
|
|
853
822
|
}
|
|
854
823
|
}
|
|
855
824
|
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
825
|
+
const RRF_RANK_CONSTANT = 60;
|
|
826
|
+
const DEFAULT_MAX_PER_HOST = 0;
|
|
827
|
+
const MULTI_PART_SUFFIXES = new Set(["co.uk", "org.uk", "com.au", "co.jp", "co.nz", "com.br", "co.in"]);
|
|
859
828
|
|
|
860
|
-
function
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
829
|
+
export function registrableDomain(url: string): string {
|
|
830
|
+
let hostname = "";
|
|
831
|
+
try {
|
|
832
|
+
hostname = new URL(url).hostname.toLowerCase().replace(/^www\./, "");
|
|
833
|
+
} catch {
|
|
834
|
+
return "";
|
|
864
835
|
}
|
|
865
|
-
|
|
836
|
+
const parts = hostname.split(".");
|
|
837
|
+
if (parts.length < 2) return hostname;
|
|
838
|
+
const lastTwo = parts.slice(-2).join(".");
|
|
839
|
+
return MULTI_PART_SUFFIXES.has(lastTwo) && parts.length >= 3 ? parts.slice(-3).join(".") : lastTwo;
|
|
866
840
|
}
|
|
867
841
|
|
|
868
|
-
export function
|
|
869
|
-
|
|
870
|
-
const
|
|
871
|
-
const
|
|
872
|
-
const
|
|
873
|
-
const bodyOnly: SearchResult[] = [];
|
|
874
|
-
const neither: SearchResult[] = [];
|
|
842
|
+
export function capByHost(docs: SearchResult[], limit: number, maxPerHost: number): SearchResult[] {
|
|
843
|
+
if (limit <= 0) return [];
|
|
844
|
+
const perHost = new Map<string, number>();
|
|
845
|
+
const capped: SearchResult[] = [];
|
|
846
|
+
const allowed = maxPerHost > 0 ? maxPerHost : Number.POSITIVE_INFINITY;
|
|
875
847
|
for (const doc of docs) {
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
const hitBody = hasAnyToken(doc.body, tokens);
|
|
883
|
-
if (hitTitle && hitBody) both.push(doc);
|
|
884
|
-
else if (hitTitle) titleOnly.push(doc);
|
|
885
|
-
else if (hitBody) bodyOnly.push(doc);
|
|
886
|
-
else neither.push(doc);
|
|
848
|
+
const domain = registrableDomain(doc.href);
|
|
849
|
+
const used = perHost.get(domain) ?? 0;
|
|
850
|
+
if (used >= allowed) continue;
|
|
851
|
+
perHost.set(domain, used + 1);
|
|
852
|
+
capped.push(doc);
|
|
853
|
+
if (capped.length >= limit) break;
|
|
887
854
|
}
|
|
888
|
-
return
|
|
855
|
+
return capped;
|
|
889
856
|
}
|
|
857
|
+
|
|
858
|
+
function isWikimediaCategory(doc: SearchResult): boolean {
|
|
859
|
+
return doc.title.includes("Category:") && doc.title.includes("Wikimedia");
|
|
860
|
+
}
|
|
861
|
+
|
|
890
862
|
async function recordSweepStats(query: string, maxResults: number, started: number, timedOutProviders: string[], resultCount: number): Promise<void> {
|
|
891
863
|
const flag = process.env.PI_UNSLOTH_WEBTOOLS_STATS?.trim();
|
|
892
864
|
if (!flag) return;
|
|
@@ -922,15 +894,20 @@ async function recordSweepStats(query: string, maxResults: number, started: numb
|
|
|
922
894
|
} catch {}
|
|
923
895
|
}
|
|
924
896
|
|
|
925
|
-
function
|
|
926
|
-
|
|
897
|
+
function selectedEngines(names: string[] | undefined): Engine[] {
|
|
898
|
+
if (!names?.length) return TEXT_ENGINES;
|
|
899
|
+
const wanted = new Set(names.map((name) => name.trim().toLowerCase()));
|
|
900
|
+
const chosen = TEXT_ENGINES.filter((engine) => wanted.has(engine.name));
|
|
901
|
+
return chosen.length ? chosen : TEXT_ENGINES;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function shuffledEngines(engines: Engine[] = TEXT_ENGINES): Engine[] {
|
|
905
|
+
const shuffled = [...engines];
|
|
927
906
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
928
907
|
const j = Math.floor(Math.random() * (i + 1));
|
|
929
908
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
930
909
|
}
|
|
931
|
-
|
|
932
|
-
const rest = shuffled.filter((e) => e.priority !== 2);
|
|
933
|
-
return wikipedia ? [wikipedia, ...rest] : shuffled;
|
|
910
|
+
return shuffled;
|
|
934
911
|
}
|
|
935
912
|
|
|
936
913
|
export async function autoTextSearch(
|
|
@@ -938,13 +915,23 @@ export async function autoTextSearch(
|
|
|
938
915
|
maxResults: number,
|
|
939
916
|
timeoutMs: number,
|
|
940
917
|
signal?: AbortSignal,
|
|
918
|
+
options: SearchEngineOptions = {},
|
|
941
919
|
): Promise<SearchResult[]> {
|
|
942
920
|
const started = Date.now();
|
|
943
|
-
const engines = shuffledEngines();
|
|
921
|
+
const engines = shuffledEngines(selectedEngines(options.engines));
|
|
944
922
|
const deadline = started + timeoutMs;
|
|
945
923
|
const seenProviders = new Set<string>();
|
|
946
924
|
const aggregator = new ResultsAggregator();
|
|
947
|
-
const
|
|
925
|
+
const engineWeights = options.engineWeights ?? {};
|
|
926
|
+
const maxPerHost = options.maxPerHost ?? DEFAULT_MAX_PER_HOST;
|
|
927
|
+
const enough = () => capByHost(aggregator.ranked(), maxResults, maxPerHost).length >= maxResults;
|
|
928
|
+
const ctx: EngineContext = {
|
|
929
|
+
region: "us-en",
|
|
930
|
+
safesearch: "moderate",
|
|
931
|
+
transport: options.transport ?? DEFAULT_ENGINE_TRANSPORT,
|
|
932
|
+
policy: options.policy ?? null,
|
|
933
|
+
impersonate: options.impersonate,
|
|
934
|
+
};
|
|
948
935
|
const controller = new AbortController();
|
|
949
936
|
let onAbort: (() => void) | undefined;
|
|
950
937
|
if (signal) {
|
|
@@ -998,18 +985,18 @@ export async function autoTextSearch(
|
|
|
998
985
|
}
|
|
999
986
|
}
|
|
1000
987
|
if (results && results.length) {
|
|
1001
|
-
aggregator.extend(results);
|
|
988
|
+
aggregator.extend(results, engineWeights[engine.name] ?? 1);
|
|
1002
989
|
seenProviders.add(engine.provider);
|
|
1003
|
-
if (
|
|
990
|
+
if (enough()) controller.abort();
|
|
1004
991
|
}
|
|
1005
992
|
};
|
|
1006
993
|
while (i < engines.length || pending.size > 0) {
|
|
1007
|
-
if (
|
|
994
|
+
if (enough() || cancelled) {
|
|
1008
995
|
controller.abort();
|
|
1009
996
|
break;
|
|
1010
997
|
}
|
|
1011
998
|
while (i < engines.length && pending.size < maxWorkers) {
|
|
1012
|
-
if (
|
|
999
|
+
if (enough() || cancelled) {
|
|
1013
1000
|
controller.abort();
|
|
1014
1001
|
break;
|
|
1015
1002
|
}
|
|
@@ -1027,7 +1014,7 @@ export async function autoTextSearch(
|
|
|
1027
1014
|
);
|
|
1028
1015
|
}
|
|
1029
1016
|
if (pending.size === 0) break;
|
|
1030
|
-
if (
|
|
1017
|
+
if (enough() || cancelled) {
|
|
1031
1018
|
controller.abort();
|
|
1032
1019
|
break;
|
|
1033
1020
|
}
|
|
@@ -1036,10 +1023,10 @@ export async function autoTextSearch(
|
|
|
1036
1023
|
await Promise.allSettled(pending);
|
|
1037
1024
|
if (onAbort && signal) signal.removeEventListener("abort", onAbort);
|
|
1038
1025
|
if (cancelled) throw new SearchCancelled();
|
|
1039
|
-
const results =
|
|
1026
|
+
const results = capByHost(aggregator.ranked(), maxResults, maxPerHost);
|
|
1040
1027
|
if (results.length) {
|
|
1041
1028
|
void recordSweepStats(query, maxResults, started, [...timedOutProviders], results.length);
|
|
1042
|
-
return results
|
|
1029
|
+
return results;
|
|
1043
1030
|
}
|
|
1044
1031
|
if (timedOutProviders.size) {
|
|
1045
1032
|
const sorted = [...timedOutProviders].sort();
|