pi-unsloth-webtools 0.9.1 → 0.10.1

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 CHANGED
@@ -38,13 +38,15 @@ Both tools display their target in the TUI tool row: `web_search "query"` and `w
38
38
 
39
39
  Mirrors Unsloth Studio's `web_search` tool:
40
40
 
41
- - Searches exactly like Studio's pinned `ddgs==9.14.4` `DDGS.text()`: the same seven engines
42
- (duckduckgo, brave, google, mojeek, yahoo, yandex, wikipedia; bing is disabled upstream),
43
- the same provider deduplication, href-dedupe aggregator with frequency ordering (hrefs are
41
+ - Searches like Studio's pinned `ddgs==9.14.4` `DDGS.text()`: the Studio engines that still work
42
+ here (duckduckgo, yandex; the others are behind bot walls, see
43
+ [Known differences from Studio](#known-differences-from-studio)) plus startpage,
44
+ the same provider deduplication, and an href-dedupe aggregator (hrefs are
44
45
  canonicalized first — `utm_*`/tracking parameters and fragments are dropped and the URL is
45
46
  re-serialized, collapsing host-case, default-port, and trailing-slash variants — so the
46
- same page found via different tracking links collapses), and the same `SimpleFilterRanker`
47
- re-ranking. Formats results identically: `Title:` / `URL:` /
47
+ same page found via different tracking links collapses). Results are fused with weighted
48
+ reciprocal-rank fusion: each engine's own ordering, not a keyword guess, decides relevance,
49
+ and the fused list is capped per registrable domain. Formats results identically:
48
50
  `Snippet:` blocks separated by `---`, ending with the hint to call `web_fetch` to
49
51
  read a full page.
50
52
  - Rate-limit, timeout, and empty-result messages mirror Studio's `_search_failure_message`.
@@ -53,6 +55,12 @@ Mirrors Unsloth Studio's `web_search` tool:
53
55
  skipped); timeouts and cancellations are never retried.
54
56
  - Sweeps stop as soon as enough results are gathered: engines still in flight are aborted
55
57
  instead of being allowed to run to their timeout.
58
+ - Engine requests go through the browser-fingerprint transport first (`webFetch.transport`,
59
+ default `tls-first`) and fall back to the plain Node transport; a refusal or bot-challenge
60
+ response (such as DuckDuckGo's 202) on one transport is retried on the other. Redirects are
61
+ followed manually, so every hop is re-checked against the website policy and private-address
62
+ literals are refused. When a SOCKS5 proxy is configured the sweep stays on the plain
63
+ transport, keeping agent and Tor routing intact.
56
64
 
57
65
  ### web_fetch
58
66
 
@@ -218,11 +226,31 @@ third-party rendering service.
218
226
  any numeric-only line at a fixed edge position where page numbers appear on at least
219
227
  half the pages (so a one-off number sharing that position is dropped too, while fused
220
228
  labels like `Page 3 of 12` survive). Studio and pymupdf4llm return them verbatim.
221
- - Search engines: Node's `fetch` TLS fingerprint differs from ddgs's `primp`
222
- impersonation, so Google/Brave/Yahoo/Yandex may block or serve consent pages more
223
- aggressively (a blocked engine simply contributes no results). User agents are a
224
- fixed browser set plus ddgs's Android Google UA generator, not `fake_useragent`'s
225
- database.
229
+ - Search engines: the sweep speaks a browser's TLS/HTTP2 shape through `wreq-js` first (the same
230
+ transport `web_fetch` uses), so engine fingerprints match Chrome rather than Node's `fetch`;
231
+ the plain Node transport is the fallback. User agents on that fallback are a fixed browser set,
232
+ not `fake_useragent`'s database.
233
+ - Startpage: an extra engine beyond Studio's set, matching newer ddgs (its Google-backed index
234
+ means it fills the `google` provider slot). It answers a plain `GET /sp/search?query=` and
235
+ serves an Anubis proof-of-work challenge to clients that do not look like a browser, so it works
236
+ through the browser-fingerprint transport and the fallback's browser headers, but not a bare
237
+ Node `fetch`. Startpage's POST endpoint and its safesearch parameter are challenge-gated and are
238
+ not used.
239
+ - Unused engines: mojeek, yahoo, google, brave and wikipedia are not part of the sweep. A
240
+ non-JavaScript client cannot get past mojeek (JavaScript challenge) or yahoo (`_bv` bot beacon),
241
+ google and brave answer rate limits and bot interstitials instead of results (HTTP 429 in
242
+ testing), and wikipedia is an API for a source the other engines already return — so the sweep
243
+ no longer spends a slot on it, and `wikipedia.org` results are ranked like any other instead of
244
+ being forced to the top. The remaining three engines can be narrowed further per machine with
245
+ `webSearch.engines` (see Configuration).
246
+ - Ranking: Studio's `SimpleFilterRanker` (keyword buckets with `wikipedia.org` pinned first) is
247
+ replaced by weighted reciprocal-rank fusion over each engine's own ordering. On the
248
+ `npm run engine:eval` query set that lifts mean precision@5 from 0.28 (keyword buckets) to 0.35 —
249
+ the fusion keeps the ranking signal the engines already computed instead of guessing from query
250
+ substrings. Weights default to uniform because the three engines overlap so little (mean Jaccard
251
+ 0.07–0.22) that weighting mostly decides which engine dominates the list rather than which result
252
+ is better. A per-domain cap exists (`webSearch.maxPerHost`) but is off by default: it measurably
253
+ costs precision, and the uncapped fused top-5 already spans 4.3 of 5 distinct domains.
226
254
  - Empty sweeps: ddgs 9.14.4 raises the last engine exception; this port reports a
227
255
  timeout whenever any engine timed out, so the timeout message is not masked by later
228
256
  generic engine failures. The timeout budget bounds the entire sweep: per-engine
@@ -231,16 +259,16 @@ third-party rendering service.
231
259
  - Proxies: Studio routes through environment proxies; this port resolves and pins the target IP and
232
260
  tunnels that connection through `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` when the proxy is a
233
261
  SOCKS5 proxy (`NO_PROXY` exclusions respected; DNS stays local for the guard). Other proxy
234
- schemes fall back to a direct connection. The search path uses the process-wide `fetch`, so an
235
- agent-level proxy dispatcher applies there too — see
236
- [Companion: rotating exit IPs](#companion-rotating-exit-ips).
262
+ schemes fall back to a direct connection. The search sweep stays on the process-wide `fetch`
263
+ whenever a SOCKS5 proxy is configured, so an agent-level proxy dispatcher still applies there —
264
+ see [Companion: rotating exit IPs](#companion-rotating-exit-ips).
237
265
  - Dedup and titles: the aggregator keys on canonicalized hrefs (`utm_*`/tracking parameters
238
266
  and fragments stripped, then the URL re-serialized); fetched HTML pages are prefixed with
239
267
  the document `<title>`. Studio keys on raw hrefs and returns the converted body alone.
240
268
  - Upstream drift: current ddgs ships ten backends (adding bing, startpage, grokipedia),
241
269
  requires a `vqd` token for DuckDuckGo, and exposes an `extract()` mode. This port
242
- deliberately pins the Studio snapshot — seven engines, bing disabled upstream, no vqd,
243
- no pagination — so engine behavior matches Studio rather than ddgs head.
270
+ uses duckduckgo and yandex from the Studio snapshot plus startpage — bing stays
271
+ disabled, no vqd, no pagination — so engine behavior matches Studio rather than ddgs head.
244
272
 
245
273
  ## When to use alternatives
246
274
 
@@ -266,8 +294,9 @@ choose the best tool per URL. No need to fork this package to add those features
266
294
 
267
295
  [`pi-tor-proxy`](https://github.com/YuGiMob/pi-tor-proxy) routes pi's in-process `fetch` traffic
268
296
  through Tor (it downloads and manages its own Tor binary) and gives each pi instance its own
269
- circuit and exit IP. The search sweep uses the process-wide `fetch`, so it leaves through the
270
- current Tor exit, and many search engines rate-limit or challenge per outgoing IP —
297
+ circuit and exit IP. With a SOCKS5 proxy configured, the search sweep stays on the process-wide
298
+ `fetch`, so it leaves through the current Tor exit, and many search engines rate-limit or
299
+ challenge per outgoing IP —
271
300
  `/tor-cycle` swaps the exit those limits are counted against, while `/tor-country` and
272
301
  `/tor-exclude` constrain which exits are used.
273
302
 
@@ -310,7 +339,10 @@ Optional settings in `~/.pi/agent/settings.json` or `.pi/settings.json` (project
310
339
  | `websitePolicy` | none | Not read from settings. Tools run unrestricted by default; `websitePolicy` is a programmatic option the host passes to `webSearch` / `fetchPageText` |
311
340
  | `unslothWebTools.allowPrivateAddresses` / `webFetch.allowPrivateAddresses` | `true` | Opt out to restore the resolved-IP SSRF guard: private/loopback/link-local hosts (localhost, LAN IPs) are refused again. Non-canonical numeric IP encodings stay blocked either way |
312
341
  | `unslothWebTools.allowLocalFiles` / `webFetch.allowLocalFiles` | `true` | Opt out to refuse local files in `web_fetch` (`file://` URLs, absolute, `~/`, or `./` paths); when enabled, PDFs are extracted and HTML converted |
313
- | `webFetch.transport` / `unslothWebTools.transport` | `tls-first` | Fetch transport order: `tls-first` (default), `direct-first`, or `off` to disable the browser-fingerprint transport entirely |
342
+ | `webFetch.transport` / `unslothWebTools.transport` | `tls-first` | Transport order for `web_fetch` and `web_search` engine requests: `tls-first` (default), `direct-first`, or `off` to disable the browser-fingerprint transport entirely |
343
+ | `webSearch.engines` / `unslothWebTools.engines` | all three (duckduckgo, yandex, startpage) | Restrict `web_search` to a subset of engine names, e.g. `["duckduckgo", "yandex"]`; unknown names are ignored, and a list that matches nothing falls back to every engine |
344
+ | `webSearch.engineWeights` / `unslothWebTools.engineWeights` | all `1` | Per-engine fusion weight, e.g. `{"startpage": 2, "yandex": 0.5}`; only positive numbers are read |
345
+ | `webSearch.maxPerHost` / `unslothWebTools.maxPerHost` | `0` (no cap) | Most results one registrable domain may contribute; `0` disables the cap |
314
346
  | `webRender.lightpandaEnabled` / `unslothWebTools.lightpandaEnabled` | `true` | Opt out to disable local Lightpanda rendering |
315
347
  | `webRender.lightpandaPath` / `unslothWebTools.lightpandaPath` | launcher installed by `scripts/install-lightpanda.sh`, else `lightpanda` on `PATH` | Path to the Lightpanda binary used for local rendering |
316
348
  | `webRender.lightpandaCommand` / `unslothWebTools.lightpandaCommand` | none | Command prefix that launches the renderer, for WSL (`["wsl.exe","-e","<path>"]`) or containers; overrides `lightpandaPath`. The fetch flags are appended to it |
@@ -358,6 +390,7 @@ npm run test:unit
358
390
  npm run test:smoke
359
391
  bash scripts/install-lightpanda.sh
360
392
  npm run camoufox:warmup
393
+ npm run engine:eval
361
394
  ```
362
395
 
363
396
  `npm run camoufox:warmup` measures what a warm Camoufox costs and buys: launch time, idle CPU and
@@ -366,6 +399,11 @@ RSS, per-fetch latency with the browser already running, and whether a persisten
366
399
  `--virtual` for a virtual display, `--pin=0` to let Camoufox rotate fingerprints, `--seconds=N`,
367
400
  `--idle=N`.
368
401
 
402
+ `npm run engine:eval` measures each search engine against a small hand-labelled developer query
403
+ set: yield, latency and failures, pairwise URL overlap, unique contribution, and offline fusion
404
+ simulations with candidate weight vectors and per-host caps. `--cache=FILE` reuses a previous run
405
+ without touching the network; `--delay`, `--timeout`, `--max` and `--transport` tune the sweep.
406
+
369
407
  ## Tests
370
408
 
371
409
  `npm test` runs the full suite. `npm run test:unit` skips the live-network smoke tests,
@@ -386,14 +424,15 @@ The suite ports Unsloth Studio's own tests for these tools:
386
424
  - `test/fetch-flow.test.ts`: GitHub README rewrite, deadline/cancellation, HTML sniffing
387
425
  (from `test_web_fetch_extraction.py`; the fetch client is injected via seams)
388
426
  - `test/engines.test.ts`: the ddgs engine port, normalizers, the XPath subset, the
389
- aggregator, the ranker, and the Wikipedia engine with a stubbed fetch
427
+ reciprocal-rank aggregator, the per-host cap, and the Startpage engine with a stubbed fetch
390
428
  - `test/pdf-parity.test.ts`: MuPDF engine capabilities, PDF 1.5 object streams,
391
429
  ASCII85Decode, font `/Differences` encodings, pymupdf4llm-style headings/links/tables
392
430
  - `test/entities.test.ts`: `decodeHtmlEntities` parity with CPython `html.unescape`,
393
431
  legacy refs, longest-prefix rule, Windows-1252 numeric mappings, invalid codepoints
394
432
  - `test/smoke.test.ts`: live network checks against real hosts, including a per-engine
395
- result-health sweep (at least two engines must return well-formed results; engines
396
- that block or reset connections from datacenter IPs count as unhealthy, not failures)
433
+ result-health sweep through the browser-fingerprint transport (at least one engine must
434
+ return well-formed results; engines that block or reset connections from datacenter IPs
435
+ count as unhealthy, not failures)
397
436
 
398
437
  - `test/tls-fetch.test.ts`: the browser-fingerprint transport with a stubbed native module, including
399
438
  transport reuse, body caps, redirect passthrough, and abort/timeout mapping
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,70 @@ 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 (attempt.status === 200 || (attempt.status >= 300 && attempt.status < 400)) {
694
+ response = attempt;
695
+ break;
696
+ }
697
+ if (response === null || response.status >= 400) response = attempt;
698
+ } catch (err) {
699
+ const mapped = classifyRequestError(err, caller, hopSignal);
700
+ if (mapped) throw mapped;
701
+ failure = err;
702
+ }
703
+ }
704
+ if (response === null) {
705
+ if (failure) throw failure;
621
706
  return null;
622
707
  }
623
- return null;
624
- }
625
- try {
626
- return await readBodyCapped(response);
627
- } catch (err) {
628
- throw mapFetchError(err);
708
+ if (response.status >= 300 && response.status < 400) {
709
+ if (!response.location) return null;
710
+ let next: string;
711
+ try {
712
+ next = new URL(response.location, target).toString();
713
+ } catch {
714
+ return null;
715
+ }
716
+ if (!engineTargetAllowed(next, policy)) return null;
717
+ if (response.status !== 307 && response.status !== 308) {
718
+ method = "GET";
719
+ body = undefined;
720
+ }
721
+ target = next;
722
+ continue;
723
+ }
724
+ if (response.status !== 200) return null;
725
+ return response.body ?? "";
629
726
  }
727
+ return null;
630
728
  }
631
729
 
632
730
  const DUCKDUCKGO: Engine = {
@@ -636,7 +734,7 @@ const DUCKDUCKGO: Engine = {
636
734
  const html = await httpPost(
637
735
  "https://html.duckduckgo.com/html/",
638
736
  { q: query, b: "", l: ctx.region },
639
- { headers: { "User-Agent": randomUserAgent() }, timeoutMs, signal },
737
+ { headers: { "User-Agent": randomUserAgent() }, timeoutMs, signal, ctx },
640
738
  );
641
739
  if (!html) return null;
642
740
  const results = extractResults(html, "//div[contains(@class, 'body')]", {
@@ -648,126 +746,15 @@ const DUCKDUCKGO: Engine = {
648
746
  },
649
747
  };
650
748
 
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
749
  const YANDEX: Engine = {
763
750
  name: "yandex",
764
751
  provider: "yandex",
765
- async search(query, _ctx, timeoutMs, signal) {
752
+ async search(query, ctx, timeoutMs, signal) {
766
753
  const searchid = 1000000 + Math.floor(Math.random() * 9000000);
767
754
  const html = await httpGet(
768
755
  "https://yandex.com/search/site/",
769
756
  { text: query, web: "1", searchid: String(searchid) },
770
- { timeoutMs, signal },
757
+ { timeoutMs, signal, ctx },
771
758
  );
772
759
  if (!html) return null;
773
760
  return extractResults(html, "//li[contains(@class, 'serp-item')]", {
@@ -778,60 +765,36 @@ const YANDEX: Engine = {
778
765
  },
779
766
  };
780
767
 
781
- const WIKIPEDIA: Engine = {
782
- name: "wikipedia",
783
- provider: "wikipedia",
784
- priority: 2,
768
+ const START_PAGE: Engine = {
769
+ name: "startpage",
770
+ provider: "google",
785
771
  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) }];
772
+ const [country, lang] = ctx.region.toLowerCase().split("-");
773
+ const html = await httpGet(
774
+ "https://www.startpage.com/sp/search",
775
+ { query, qsr: `${lang}_${country.toUpperCase()}` },
776
+ { headers: { Referer: "https://www.startpage.com/" }, timeoutMs, signal, ctx },
777
+ );
778
+ if (!html) return null;
779
+ return extractResults(html, "//div[contains(@class, 'result')][./a]", {
780
+ title: ".//h2//text()",
781
+ href: "./a/@href",
782
+ body: ".//p//text()",
783
+ });
821
784
  },
822
785
  };
823
786
 
824
- export const TEXT_ENGINES: Engine[] = [DUCKDUCKGO, BRAVE, GOOGLE, MOJEEK, YAHOO, YANDEX, WIKIPEDIA];
787
+ export const TEXT_ENGINES: Engine[] = [DUCKDUCKGO, YANDEX, START_PAGE];
825
788
 
826
789
  export class ResultsAggregator {
827
790
  private cache = new Map<string, SearchResult>();
828
- private counter = new Map<string, number>();
791
+ private scores = new Map<string, number>();
829
792
 
830
793
  get size(): number {
831
794
  return this.cache.size;
832
795
  }
833
796
 
834
- append(item: SearchResult): void {
797
+ append(item: SearchResult, weight = 1, rank = 1): void {
835
798
  if (typeof item.href !== "string" || !item.href.trim()) return;
836
799
  const key = canonicalizeHref(item.href);
837
800
  if (!key) return;
@@ -839,54 +802,66 @@ export class ResultsAggregator {
839
802
  if (!existing || item.body.length > existing.body.length) {
840
803
  this.cache.set(key, { ...item, href: key });
841
804
  }
842
- this.counter.set(key, (this.counter.get(key) ?? 0) + 1);
805
+ this.scores.set(key, (this.scores.get(key) ?? 0) + weight / (RRF_RANK_CONSTANT + rank));
843
806
  }
844
807
 
845
- extend(items: SearchResult[]): void {
846
- for (const item of items) this.append(item);
808
+ extend(items: SearchResult[], weight = 1): void {
809
+ const seen = new Set<string>();
810
+ items.forEach((item, index) => {
811
+ const key = canonicalizeHref(item.href);
812
+ if (!key) return;
813
+ const first = !seen.has(key);
814
+ seen.add(key);
815
+ this.append(item, first ? weight : 0, index + 1);
816
+ });
847
817
  }
848
818
 
849
- extractDicts(): SearchResult[] {
850
- return [...this.counter.entries()]
851
- .sort((a, b) => b[1] - a[1])
852
- .map(([key]) => this.cache.get(key)!);
819
+ ranked(): SearchResult[] {
820
+ return [...this.cache.entries()]
821
+ .filter(([, doc]) => !isWikimediaCategory(doc))
822
+ .map(([key, doc]) => ({ doc, score: this.scores.get(key) ?? 0 }))
823
+ .sort((a, b) => b.score - a.score || a.doc.href.localeCompare(b.doc.href))
824
+ .map((entry) => entry.doc);
853
825
  }
854
826
  }
855
827
 
856
- function extractTokens(query: string): Set<string> {
857
- return new Set(query.toLowerCase().split(/\W+/u).filter((t) => t.length >= 3));
858
- }
828
+ const RRF_RANK_CONSTANT = 60;
829
+ const DEFAULT_MAX_PER_HOST = 0;
830
+ const MULTI_PART_SUFFIXES = new Set(["co.uk", "org.uk", "com.au", "co.jp", "co.nz", "com.br", "co.in"]);
859
831
 
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;
832
+ export function registrableDomain(url: string): string {
833
+ let hostname = "";
834
+ try {
835
+ hostname = new URL(url).hostname.toLowerCase().replace(/^www\./, "");
836
+ } catch {
837
+ return "";
864
838
  }
865
- return false;
839
+ const parts = hostname.split(".");
840
+ if (parts.length < 2) return hostname;
841
+ const lastTwo = parts.slice(-2).join(".");
842
+ return MULTI_PART_SUFFIXES.has(lastTwo) && parts.length >= 3 ? parts.slice(-3).join(".") : lastTwo;
866
843
  }
867
844
 
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[] = [];
845
+ export function capByHost(docs: SearchResult[], limit: number, maxPerHost: number): SearchResult[] {
846
+ if (limit <= 0) return [];
847
+ const perHost = new Map<string, number>();
848
+ const capped: SearchResult[] = [];
849
+ const allowed = maxPerHost > 0 ? maxPerHost : Number.POSITIVE_INFINITY;
875
850
  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);
851
+ const domain = registrableDomain(doc.href);
852
+ const used = perHost.get(domain) ?? 0;
853
+ if (used >= allowed) continue;
854
+ perHost.set(domain, used + 1);
855
+ capped.push(doc);
856
+ if (capped.length >= limit) break;
887
857
  }
888
- return [...wiki, ...both, ...titleOnly, ...bodyOnly, ...neither];
858
+ return capped;
889
859
  }
860
+
861
+ function isWikimediaCategory(doc: SearchResult): boolean {
862
+ return doc.title.includes("Category:") && doc.title.includes("Wikimedia");
863
+ }
864
+
890
865
  async function recordSweepStats(query: string, maxResults: number, started: number, timedOutProviders: string[], resultCount: number): Promise<void> {
891
866
  const flag = process.env.PI_UNSLOTH_WEBTOOLS_STATS?.trim();
892
867
  if (!flag) return;
@@ -922,15 +897,20 @@ async function recordSweepStats(query: string, maxResults: number, started: numb
922
897
  } catch {}
923
898
  }
924
899
 
925
- function shuffledEngines(): Engine[] {
926
- const shuffled = [...TEXT_ENGINES];
900
+ function selectedEngines(names: string[] | undefined): Engine[] {
901
+ if (!names?.length) return TEXT_ENGINES;
902
+ const wanted = new Set(names.map((name) => name.trim().toLowerCase()));
903
+ const chosen = TEXT_ENGINES.filter((engine) => wanted.has(engine.name));
904
+ return chosen.length ? chosen : TEXT_ENGINES;
905
+ }
906
+
907
+ function shuffledEngines(engines: Engine[] = TEXT_ENGINES): Engine[] {
908
+ const shuffled = [...engines];
927
909
  for (let i = shuffled.length - 1; i > 0; i--) {
928
910
  const j = Math.floor(Math.random() * (i + 1));
929
911
  [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
930
912
  }
931
- const wikipedia = shuffled.find((e) => e.priority === 2);
932
- const rest = shuffled.filter((e) => e.priority !== 2);
933
- return wikipedia ? [wikipedia, ...rest] : shuffled;
913
+ return shuffled;
934
914
  }
935
915
 
936
916
  export async function autoTextSearch(
@@ -938,13 +918,23 @@ export async function autoTextSearch(
938
918
  maxResults: number,
939
919
  timeoutMs: number,
940
920
  signal?: AbortSignal,
921
+ options: SearchEngineOptions = {},
941
922
  ): Promise<SearchResult[]> {
942
923
  const started = Date.now();
943
- const engines = shuffledEngines();
924
+ const engines = shuffledEngines(selectedEngines(options.engines));
944
925
  const deadline = started + timeoutMs;
945
926
  const seenProviders = new Set<string>();
946
927
  const aggregator = new ResultsAggregator();
947
- const ctx: EngineContext = { region: "us-en", safesearch: "moderate" };
928
+ const engineWeights = options.engineWeights ?? {};
929
+ const maxPerHost = options.maxPerHost ?? DEFAULT_MAX_PER_HOST;
930
+ const enough = () => capByHost(aggregator.ranked(), maxResults, maxPerHost).length >= maxResults;
931
+ const ctx: EngineContext = {
932
+ region: "us-en",
933
+ safesearch: "moderate",
934
+ transport: options.transport ?? DEFAULT_ENGINE_TRANSPORT,
935
+ policy: options.policy ?? null,
936
+ impersonate: options.impersonate,
937
+ };
948
938
  const controller = new AbortController();
949
939
  let onAbort: (() => void) | undefined;
950
940
  if (signal) {
@@ -998,18 +988,18 @@ export async function autoTextSearch(
998
988
  }
999
989
  }
1000
990
  if (results && results.length) {
1001
- aggregator.extend(results);
991
+ aggregator.extend(results, engineWeights[engine.name] ?? 1);
1002
992
  seenProviders.add(engine.provider);
1003
- if (aggregator.size >= maxResults) controller.abort();
993
+ if (enough()) controller.abort();
1004
994
  }
1005
995
  };
1006
996
  while (i < engines.length || pending.size > 0) {
1007
- if (aggregator.size >= maxResults || cancelled) {
997
+ if (enough() || cancelled) {
1008
998
  controller.abort();
1009
999
  break;
1010
1000
  }
1011
1001
  while (i < engines.length && pending.size < maxWorkers) {
1012
- if (aggregator.size >= maxResults || cancelled) {
1002
+ if (enough() || cancelled) {
1013
1003
  controller.abort();
1014
1004
  break;
1015
1005
  }
@@ -1027,7 +1017,7 @@ export async function autoTextSearch(
1027
1017
  );
1028
1018
  }
1029
1019
  if (pending.size === 0) break;
1030
- if (aggregator.size >= maxResults || cancelled) {
1020
+ if (enough() || cancelled) {
1031
1021
  controller.abort();
1032
1022
  break;
1033
1023
  }
@@ -1036,10 +1026,10 @@ export async function autoTextSearch(
1036
1026
  await Promise.allSettled(pending);
1037
1027
  if (onAbort && signal) signal.removeEventListener("abort", onAbort);
1038
1028
  if (cancelled) throw new SearchCancelled();
1039
- const results = rankResults(aggregator.extractDicts(), query);
1029
+ const results = capByHost(aggregator.ranked(), maxResults, maxPerHost);
1040
1030
  if (results.length) {
1041
1031
  void recordSweepStats(query, maxResults, started, [...timedOutProviders], results.length);
1042
- return results.slice(0, maxResults);
1032
+ return results;
1043
1033
  }
1044
1034
  if (timedOutProviders.size) {
1045
1035
  const sorted = [...timedOutProviders].sort();
package/index.ts CHANGED
@@ -10,7 +10,13 @@ import {
10
10
  type FetchPageOutcome,
11
11
  } from "./web-fetch.ts";
12
12
  import { renderPageWithLightpanda as defaultRenderLocalPageText } from "./lightpanda.ts";
13
- import { loadDefaultFetchSettings, loadDefaultFetchTimeoutMs, loadLightpandaSettings } from "./settings.ts";
13
+ import {
14
+ loadDefaultEngines,
15
+ loadDefaultEngineWeights,
16
+ loadDefaultFetchSettings,
17
+ loadDefaultMaxPerHost,
18
+ loadLightpandaSettings,
19
+ } from "./settings.ts";
14
20
 
15
21
  function toolCallLine(theme: Theme, name: string, detail: string) {
16
22
  const line = theme.fg("toolTitle", theme.bold(name)) + (detail ? ` ${theme.fg("accent", detail)}` : "");
@@ -179,12 +185,20 @@ export function createWebTools(deps: WebToolsDeps = {}) {
179
185
  onUpdate?.({ content: [{ type: "text", text: "Searching the web..." }], details: {} });
180
186
  const timeoutParam = positiveNumber(params.timeoutMs);
181
187
  const searchCwd = (_ctx as ExtensionContext | undefined)?.cwd;
182
- const searchTimeoutMs = timeoutParam ?? (await loadDefaultFetchTimeoutMs(searchCwd)) ?? SEARCH_TIMEOUT_MS;
188
+ const searchSettings = await loadDefaultFetchSettings(searchCwd);
189
+ const searchEngines = await loadDefaultEngines(searchCwd);
190
+ const searchEngineWeights = await loadDefaultEngineWeights(searchCwd);
191
+ const searchMaxPerHost = await loadDefaultMaxPerHost(searchCwd);
192
+ const searchTimeoutMs = timeoutParam ?? searchSettings.timeoutMs ?? SEARCH_TIMEOUT_MS;
183
193
  const text = await webSearch(params.query, {
184
194
  signal: signal ?? undefined,
185
195
  timeoutMs: searchTimeoutMs,
186
196
  maxResults: positiveNumber(params.maxResults),
187
197
  cwd: searchCwd,
198
+ transport: searchSettings.transport,
199
+ engines: searchEngines,
200
+ engineWeights: searchEngineWeights,
201
+ maxPerHost: searchMaxPerHost,
188
202
  });
189
203
  return { content: [{ type: "text", text }], details: {} };
190
204
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-unsloth-webtools",
3
- "version": "0.9.1",
3
+ "version": "0.10.1",
4
4
  "type": "module",
5
5
  "description": "Pi extension: web_search and web_fetch tools that began as a port of the Unsloth Studio codebase and now diverge from it (multi-engine search, opt-in SSRF guard, HTML-to-Markdown extraction)",
6
6
  "main": "index.ts",
@@ -60,6 +60,7 @@
60
60
  "test:smoke": "vitest run test/smoke.test.ts",
61
61
  "test:lightpanda": "vitest run test/lightpanda-live.test.ts",
62
62
  "camoufox:warmup": "node scripts/camoufox-warmup.ts",
63
+ "engine:eval": "node scripts/engine-eval.ts",
63
64
  "typecheck": "tsc --noEmit",
64
65
  "check:package": "node -e \"const fs=require('fs');const pkg=require('./package.json');const listed=new Set(pkg.files);const bad=[];for(const f of pkg.files){if(!fs.existsSync(f))bad.push('missing file: '+f)}for(const f of fs.readdirSync('.').filter(f=>f.endsWith('.ts'))){if(!listed.has(f))bad.push('unlisted source: '+f)}if(bad.length){console.error(bad.join('\\n'));process.exit(1)}\"",
65
66
  "prepublishOnly": "npm run typecheck && npm run lint && npm run test:unit && npm run check:package && npm run check:publint",
package/settings.ts CHANGED
@@ -50,6 +50,26 @@ function pickBoolean(data: Record<string, unknown>, paths: string[][]): boolean
50
50
  return undefined;
51
51
  }
52
52
 
53
+ function pickNumberRecord(data: Record<string, unknown>, paths: string[][]): Record<string, number> | undefined {
54
+ for (const path of paths) {
55
+ let cur: unknown = data;
56
+ for (const key of path) {
57
+ if (cur && typeof cur === "object" && !Array.isArray(cur)) cur = (cur as Record<string, unknown>)[key];
58
+ else {
59
+ cur = undefined;
60
+ break;
61
+ }
62
+ }
63
+ if (!cur || typeof cur !== "object" || Array.isArray(cur)) continue;
64
+ const record: Record<string, number> = {};
65
+ for (const [key, value] of Object.entries(cur as Record<string, unknown>)) {
66
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) record[key] = value;
67
+ }
68
+ if (Object.keys(record).length) return record;
69
+ }
70
+ return undefined;
71
+ }
72
+
53
73
  function pickStringArray(data: Record<string, unknown>, paths: string[][]): string[] | undefined {
54
74
  for (const path of paths) {
55
75
  let cur: unknown = data;
@@ -133,6 +153,21 @@ const LIGHTPANDA_COMMAND_PATHS: string[][] = [
133
153
  ["unslothWebTools", "lightpandaCommand"],
134
154
  ["webRender", "lightpandaCommand"],
135
155
  ];
156
+
157
+ const ENGINE_WEIGHTS_PATHS: string[][] = [
158
+ ["unslothWebTools", "engineWeights"],
159
+ ["webSearch", "engineWeights"],
160
+ ];
161
+
162
+ const MAX_PER_HOST_PATHS: string[][] = [
163
+ ["unslothWebTools", "maxPerHost"],
164
+ ["webSearch", "maxPerHost"],
165
+ ];
166
+
167
+ const ENGINE_PATHS: string[][] = [
168
+ ["unslothWebTools", "engines"],
169
+ ["webSearch", "engines"],
170
+ ];
136
171
  function clampMaxResults(value: number): number {
137
172
  return Math.min(20, Math.max(1, value));
138
173
  }
@@ -163,6 +198,28 @@ export async function loadDefaultMaxResults(cwd?: string): Promise<number> {
163
198
  return result;
164
199
  }
165
200
 
201
+ export async function loadDefaultEngines(cwd?: string): Promise<string[] | undefined> {
202
+ let result: string[] | undefined;
203
+ for (const data of await settingsEntries(cwd)) {
204
+ const candidate = pickStringArray(data, ENGINE_PATHS);
205
+ if (candidate !== undefined) result = candidate;
206
+ }
207
+ return result;
208
+ }
209
+
210
+ export async function loadDefaultEngineWeights(cwd?: string): Promise<Record<string, number> | undefined> {
211
+ let result: Record<string, number> | undefined;
212
+ for (const data of await settingsEntries(cwd)) {
213
+ const candidate = pickNumberRecord(data, ENGINE_WEIGHTS_PATHS);
214
+ if (candidate !== undefined) result = candidate;
215
+ }
216
+ return result;
217
+ }
218
+
219
+ export async function loadDefaultMaxPerHost(cwd?: string): Promise<number | undefined> {
220
+ return loadFetchSetting(cwd, MAX_PER_HOST_PATHS, (n) => n >= 0);
221
+ }
222
+
166
223
  async function loadFetchSetting(cwd: string | undefined, paths: string[][], valid: (n: number) => boolean): Promise<number | undefined> {
167
224
  let result: number | undefined;
168
225
  for (const data of await settingsEntries(cwd)) {
package/tls-fetch.ts CHANGED
@@ -49,15 +49,19 @@ interface ImpersonationModule {
49
49
 
50
50
  export type ImpersonationLoader = () => Promise<ImpersonationModule | null>;
51
51
 
52
+ export type FetchTransport = "tls-first" | "direct-first" | "off";
53
+
52
54
  export interface TlsHopOptions {
53
55
  url: URL;
54
- pinnedIp: string;
55
- family: number;
56
+ pinnedIp?: string;
57
+ family?: number;
56
58
  timeoutMs: number;
57
59
  signal?: AbortSignal;
58
60
  maxBytes: number;
59
61
  maxPdfBytes?: number;
60
62
  profile?: string;
63
+ method?: string;
64
+ body?: string;
61
65
  extraHeaders?: Record<string, string>;
62
66
  loader?: ImpersonationLoader;
63
67
  }
@@ -85,7 +89,7 @@ async function loadImpersonationModule(): Promise<ImpersonationModule | null> {
85
89
  const transports = new Map<string, Promise<ImpersonationTransport>>();
86
90
 
87
91
  function transportKey(options: TlsHopOptions): string {
88
- return `${options.profile ?? DEFAULT_PROFILE}|${options.url.hostname}|${options.pinnedIp}|${options.family}`;
92
+ return `${options.profile ?? DEFAULT_PROFILE}|${options.url.hostname}|${options.pinnedIp ?? ""}|${options.family ?? 0}`;
89
93
  }
90
94
 
91
95
  function evictTransports(): void {
@@ -107,12 +111,13 @@ async function transportFor(
107
111
  const key = transportKey(options);
108
112
  const existing = transports.get(key);
109
113
  if (existing) return existing;
114
+ const transportOptions: Record<string, unknown> = {
115
+ browser: options.profile ?? DEFAULT_PROFILE,
116
+ os: DEFAULT_OS,
117
+ };
118
+ if (options.pinnedIp) transportOptions.resolve = { [options.url.hostname]: options.pinnedIp };
110
119
  const created = module
111
- .createTransport({
112
- resolve: { [options.url.hostname]: options.pinnedIp },
113
- browser: options.profile ?? DEFAULT_PROFILE,
114
- os: DEFAULT_OS,
115
- })
120
+ .createTransport(transportOptions)
116
121
  .catch((error: unknown) => {
117
122
  transports.delete(key);
118
123
  throw error;
@@ -179,12 +184,15 @@ export async function impersonatedRequest(options: TlsHopOptions): Promise<TlsHo
179
184
  const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
180
185
  let response: ImpersonationResponse;
181
186
  try {
182
- response = await module.fetch(options.url.toString(), {
187
+ const init: Record<string, unknown> = {
183
188
  transport,
189
+ method: options.method ?? "GET",
184
190
  redirect: "manual",
185
191
  signal,
186
192
  headers: hopHeaders(options.extraHeaders),
187
- });
193
+ };
194
+ if (options.body !== undefined) init.body = options.body;
195
+ response = await module.fetch(options.url.toString(), init);
188
196
  } catch (error) {
189
197
  throw new Error(failureMessage(error, options.signal, signal));
190
198
  }
package/web-fetch.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  stripIpv6Brackets,
24
24
  type WebsitePolicy,
25
25
  } from "./web-access.ts";
26
- import { impersonatedRequest, type TlsHopOptions, type TlsHopResponse } from "./tls-fetch.ts";
26
+ import { impersonatedRequest, type FetchTransport, type TlsHopOptions, type TlsHopResponse } from "./tls-fetch.ts";
27
27
  import { collapseWhitespace, decodeHtmlEntities, feedHtml, htmlToMarkdown, visibleChars } from "./html-to-md.ts";
28
28
  import type { AttrDict } from "./html-to-md.ts";
29
29
  import { INVALID_CHARREFS } from "./entities.ts";
@@ -210,7 +210,7 @@ export interface ResolvedHost {
210
210
  alternates?: { ip: string; family: number }[];
211
211
  }
212
212
 
213
- export type FetchTransport = "tls-first" | "direct-first" | "off";
213
+ export type { FetchTransport };
214
214
 
215
215
  type TransportKind = "direct" | "tls";
216
216
  export interface FetchSeams {
package/web-search.ts CHANGED
@@ -6,8 +6,10 @@ import {
6
6
  EmptySweepError,
7
7
  SearchCancelled,
8
8
  SearchTimeoutError,
9
+ type SearchEngineOptions,
9
10
  type SearchResult,
10
11
  } from "./engines.ts";
12
+ import type { FetchTransport } from "./tls-fetch.ts";
11
13
 
12
14
  export { EmptySweepError, SearchCancelled, SearchTimeoutError } from "./engines.ts";
13
15
 
@@ -24,15 +26,20 @@ export type SearchClient = (
24
26
  maxResults: number,
25
27
  signal?: AbortSignal,
26
28
  timeoutMs?: number,
29
+ options?: SearchEngineOptions,
27
30
  ) => Promise<SearchResult[]>;
28
31
  export async function ddgSearch(
29
32
  query: string,
30
33
  maxResults = MAX_RESULTS,
31
34
  signal?: AbortSignal,
32
35
  timeoutMs = SEARCH_TIMEOUT_MS,
36
+ options: SearchEngineOptions = {},
33
37
  ): Promise<SearchResult[]> {
34
38
  if (signal?.aborted) throw new SearchCancelled();
35
- return autoTextSearch(query, maxResults, timeoutMs, signal);
39
+ return autoTextSearch(query, maxResults, timeoutMs, signal, {
40
+ ...options,
41
+ transport: options.transport ?? "tls-first",
42
+ });
36
43
  }
37
44
 
38
45
  const POLICY_OVERFETCH = 4;
@@ -44,6 +51,10 @@ export interface WebSearchOptions {
44
51
  websitePolicy?: WebsitePolicy | null;
45
52
  client?: SearchClient;
46
53
  cwd?: string;
54
+ transport?: FetchTransport;
55
+ engines?: string[];
56
+ engineWeights?: Record<string, number>;
57
+ maxPerHost?: number;
47
58
  }
48
59
 
49
60
  export { loadDefaultMaxResults };
@@ -71,7 +82,13 @@ export async function webSearch(
71
82
  (policy?.blockedDomains?.length ?? 0) > 0,
72
83
  );
73
84
  const wanted = restricted ? maxResults * POLICY_OVERFETCH : maxResults;
74
- const results = await client(effectiveQuery, wanted, signal, timeoutMs);
85
+ const results = await client(effectiveQuery, wanted, signal, timeoutMs, {
86
+ transport: options.transport,
87
+ policy,
88
+ engines: options.engines,
89
+ engineWeights: options.engineWeights,
90
+ maxPerHost: options.maxPerHost,
91
+ });
75
92
  if (signal?.aborted) return "Search cancelled.";
76
93
  if (!results.length) return EMPTY_SEARCH_RESULTS[0];
77
94
  const allowed: SearchResult[] = [];