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 +60 -21
- package/engines.ts +257 -267
- package/index.ts +16 -2
- package/package.json +2 -1
- package/settings.ts +57 -0
- package/tls-fetch.ts +18 -10
- package/web-fetch.ts +2 -2
- package/web-search.ts +19 -2
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
|
|
42
|
-
(duckduckgo,
|
|
43
|
-
|
|
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)
|
|
47
|
-
|
|
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:
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
235
|
-
agent-level proxy dispatcher applies there
|
|
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
|
-
|
|
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.
|
|
270
|
-
current Tor exit, and many search engines rate-limit or
|
|
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` |
|
|
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
|
|
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
|
|
396
|
-
that block or reset connections from datacenter IPs
|
|
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 {
|
|
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,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
|
|
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 (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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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,
|
|
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
|
|
782
|
-
name: "
|
|
783
|
-
provider: "
|
|
784
|
-
priority: 2,
|
|
768
|
+
const START_PAGE: Engine = {
|
|
769
|
+
name: "startpage",
|
|
770
|
+
provider: "google",
|
|
785
771
|
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) }];
|
|
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,
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
850
|
-
return [...this.
|
|
851
|
-
.
|
|
852
|
-
.map(([key]) => this.
|
|
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
|
-
|
|
857
|
-
|
|
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
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
|
|
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
|
|
869
|
-
|
|
870
|
-
const
|
|
871
|
-
const
|
|
872
|
-
const
|
|
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
|
-
|
|
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);
|
|
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
|
|
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
|
|
926
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
993
|
+
if (enough()) controller.abort();
|
|
1004
994
|
}
|
|
1005
995
|
};
|
|
1006
996
|
while (i < engines.length || pending.size > 0) {
|
|
1007
|
-
if (
|
|
997
|
+
if (enough() || cancelled) {
|
|
1008
998
|
controller.abort();
|
|
1009
999
|
break;
|
|
1010
1000
|
}
|
|
1011
1001
|
while (i < engines.length && pending.size < maxWorkers) {
|
|
1012
|
-
if (
|
|
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 (
|
|
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 =
|
|
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
|
|
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 {
|
|
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
|
|
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.
|
|
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
|
|
55
|
-
family
|
|
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
|
-
|
|
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
|
|
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[] = [];
|