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/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 { MAX_SIGNAL_TIMEOUT_MS } from "./web-access.ts";
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
- function googleUserAgent(): string {
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
- function yahooExtractUrl(raw: string): string {
504
- const afterRu = raw.split("/RU=", 2)[1] ?? "";
505
- const t = afterRu.split("/RK=", 1)[0].split("/RS=", 1)[0];
506
- return unquotePlus(t);
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
- function mapFetchError(err: unknown): never {
582
- if (err instanceof DOMException && err.name === "TimeoutError") throw new SearchTimeoutError();
583
- if (err instanceof DOMException && err.name === "AbortError") throw new SearchCancelled();
584
- throw err;
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 signals: AbortSignal[] = [AbortSignal.timeout(timeoutMs)];
605
- if (options.signal) signals.push(options.signal);
606
- let response: Response;
607
- try {
608
- response = await fetch(url, {
609
- method: options.method ?? "GET",
610
- headers,
611
- body: options.method === "POST" ? options.body : undefined,
612
- signal: AbortSignal.any(signals),
613
- });
614
- } catch (err) {
615
- throw mapFetchError(err);
616
- }
617
- if (response.status !== 200) {
618
- try {
619
- await response.body?.cancel();
620
- } catch {
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
- return null;
624
- }
625
- try {
626
- return await readBodyCapped(response);
627
- } catch (err) {
628
- throw mapFetchError(err);
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, _ctx, timeoutMs, signal) {
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 WIKIPEDIA: Engine = {
782
- name: "wikipedia",
783
- provider: "wikipedia",
784
- priority: 2,
765
+ const START_PAGE: Engine = {
766
+ name: "startpage",
767
+ provider: "google",
785
768
  async search(query, ctx, timeoutMs, signal) {
786
- const started = Date.now();
787
- const lang = ctx.region.toLowerCase().split("-")[1] ?? "en";
788
- const encoded = encodeURIComponent(query);
789
- const opensearchUrl =
790
- `https://${lang}.wikipedia.org/w/api.php?action=opensearch&profile=fuzzy&limit=1&search=${encoded}`;
791
- const opensearch = await httpGet(opensearchUrl, {}, { timeoutMs, signal });
792
- if (!opensearch) return null;
793
- let data: unknown;
794
- try {
795
- data = JSON.parse(opensearch);
796
- } catch {
797
- return null;
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, BRAVE, GOOGLE, MOJEEK, YAHOO, YANDEX, WIKIPEDIA];
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 counter = new Map<string, number>();
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.counter.set(key, (this.counter.get(key) ?? 0) + 1);
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
- for (const item of items) this.append(item);
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
- extractDicts(): SearchResult[] {
850
- return [...this.counter.entries()]
851
- .sort((a, b) => b[1] - a[1])
852
- .map(([key]) => this.cache.get(key)!);
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
- function extractTokens(query: string): Set<string> {
857
- return new Set(query.toLowerCase().split(/\W+/u).filter((t) => t.length >= 3));
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 hasAnyToken(text: string, tokens: Set<string>): boolean {
861
- const lower = text.toLowerCase();
862
- for (const token of tokens) {
863
- if (lower.includes(token)) return true;
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
- return false;
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 rankResults(docs: SearchResult[], query: string): SearchResult[] {
869
- const tokens = extractTokens(query);
870
- const wiki: SearchResult[] = [];
871
- const both: SearchResult[] = [];
872
- const titleOnly: SearchResult[] = [];
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
- if (doc.title.includes("Category:") && doc.title.includes("Wikimedia")) continue;
877
- if (doc.href.includes("wikipedia.org")) {
878
- wiki.push(doc);
879
- continue;
880
- }
881
- const hitTitle = hasAnyToken(doc.title, tokens);
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 [...wiki, ...both, ...titleOnly, ...bodyOnly, ...neither];
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 shuffledEngines(): Engine[] {
926
- const shuffled = [...TEXT_ENGINES];
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
- const wikipedia = shuffled.find((e) => e.priority === 2);
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 ctx: EngineContext = { region: "us-en", safesearch: "moderate" };
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 (aggregator.size >= maxResults) controller.abort();
990
+ if (enough()) controller.abort();
1004
991
  }
1005
992
  };
1006
993
  while (i < engines.length || pending.size > 0) {
1007
- if (aggregator.size >= maxResults || cancelled) {
994
+ if (enough() || cancelled) {
1008
995
  controller.abort();
1009
996
  break;
1010
997
  }
1011
998
  while (i < engines.length && pending.size < maxWorkers) {
1012
- if (aggregator.size >= maxResults || cancelled) {
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 (aggregator.size >= maxResults || cancelled) {
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 = rankResults(aggregator.extractDicts(), query);
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.slice(0, maxResults);
1029
+ return results;
1043
1030
  }
1044
1031
  if (timedOutProviders.size) {
1045
1032
  const sorted = [...timedOutProviders].sort();