pi-unsloth-webtools 0.4.0 → 0.4.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 +3 -0
- package/package.json +1 -1
- package/web-fetch.ts +65 -24
package/README.md
CHANGED
|
@@ -61,6 +61,9 @@ Port of Studio's `_fetch_page_text` / `_fetch_url_raw` pipeline:
|
|
|
61
61
|
When a host publishes both IPv4 and IPv6 addresses, IPv4 is preferred (broken IPv6 routes
|
|
62
62
|
cannot stall a fetch), and a connection failure falls back to the next validated address for
|
|
63
63
|
the same host before giving up.
|
|
64
|
+
- Transient DNS failures (EAI_AGAIN, resolver timeouts, connection refusals) are retried once
|
|
65
|
+
with a short backoff inside the same deadline, so a brief resolver hiccup does not fail a
|
|
66
|
+
fetch; the deadline abort cuts a retry short when no budget remains.
|
|
64
67
|
- GitHub repo root pages are rewritten to the unauthenticated README API
|
|
65
68
|
(`Accept: application/vnd.github.raw+json`), falling back to the raw README URL
|
|
66
69
|
(`raw.githubusercontent.com`, no API rate limit) and then to the HTML page on failure.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-unsloth-webtools",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Pi extension: web_search and web_fetch tools ported from the Unsloth Studio codebase (DuckDuckGo search, SSRF-safe direct fetching, HTML-to-Markdown extraction)",
|
|
6
6
|
"main": "index.ts",
|
package/web-fetch.ts
CHANGED
|
@@ -28,6 +28,8 @@ const MAX_DECOMPRESSED_BYTES = 64 * 1024 * 1024;
|
|
|
28
28
|
const META_VALUE_MAX_CHARS = 300;
|
|
29
29
|
const MAX_PDF_FETCH_BYTES = 10 * 1024 * 1024;
|
|
30
30
|
const MAX_REQUESTS = 5;
|
|
31
|
+
const MAX_DNS_ATTEMPTS = 2;
|
|
32
|
+
const DNS_RETRY_BACKOFF_MS = 250;
|
|
31
33
|
export const DEFAULT_FETCH_TIMEOUT_MS = 60_000;
|
|
32
34
|
export { loadDefaultFetchMaxChars, loadDefaultFetchTimeoutMs, loadDefaultFetchSettings } from "./settings.ts";
|
|
33
35
|
function remainingMs(deadline: number, now: () => number): number {
|
|
@@ -522,34 +524,73 @@ function decodeTis620(bytes: Buffer): string {
|
|
|
522
524
|
}
|
|
523
525
|
|
|
524
526
|
|
|
527
|
+
const TRANSIENT_DNS_ERROR_CODES = new Set([
|
|
528
|
+
"EAI_AGAIN",
|
|
529
|
+
"ETIMEDOUT",
|
|
530
|
+
"ECONNREFUSED",
|
|
531
|
+
"ENETUNREACH",
|
|
532
|
+
"EHOSTUNREACH",
|
|
533
|
+
]);
|
|
534
|
+
|
|
535
|
+
function isTransientDnsError(err: unknown): boolean {
|
|
536
|
+
if (!(err instanceof Error)) return false;
|
|
537
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
538
|
+
return typeof code === "string" && TRANSIENT_DNS_ERROR_CODES.has(code);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {
|
|
542
|
+
return new Promise((resolve) => {
|
|
543
|
+
if (signal?.aborted) return resolve();
|
|
544
|
+
const timer = setTimeout(resolve, ms);
|
|
545
|
+
signal?.addEventListener("abort", () => {
|
|
546
|
+
clearTimeout(timer);
|
|
547
|
+
resolve();
|
|
548
|
+
}, { once: true });
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
525
552
|
async function resolveAndValidate(hostname: string, signal?: AbortSignal): Promise<ResolvedHost> {
|
|
526
|
-
let
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
signal
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
553
|
+
let lastError: unknown = null;
|
|
554
|
+
for (let attempt = 0; attempt < MAX_DNS_ATTEMPTS; attempt++) {
|
|
555
|
+
if (signal?.aborted) break;
|
|
556
|
+
if (attempt > 0) {
|
|
557
|
+
await sleepAbortable(DNS_RETRY_BACKOFF_MS, signal);
|
|
558
|
+
if (signal?.aborted) break;
|
|
559
|
+
}
|
|
560
|
+
try {
|
|
561
|
+
const lookupOptions: LookupAllOptions & { signal?: AbortSignal } = {
|
|
562
|
+
all: true,
|
|
563
|
+
verbatim: true,
|
|
564
|
+
signal,
|
|
565
|
+
};
|
|
566
|
+
const addresses = await dnsLookup(hostname, lookupOptions);
|
|
567
|
+
if (!addresses.length) {
|
|
568
|
+
return { ok: false, reason: `Failed to resolve host: no addresses for '${hostname}'`, ip: "", family: 0 };
|
|
569
|
+
}
|
|
570
|
+
for (const entry of addresses) {
|
|
571
|
+
if (!isPublicIp(entry.address)) {
|
|
572
|
+
return { ok: false, reason: `Blocked: refusing to fetch the non-public address ${entry.address}.`, ip: "", family: 0 };
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
addresses.sort((a, b) => (a.family === 4 ? 0 : 1) - (b.family === 4 ? 0 : 1));
|
|
576
|
+
const first = addresses[0];
|
|
577
|
+
return {
|
|
578
|
+
ok: true,
|
|
579
|
+
reason: "",
|
|
580
|
+
ip: first.address,
|
|
581
|
+
family: first.family,
|
|
582
|
+
alternates: addresses.slice(1).map((entry) => ({ ip: entry.address, family: entry.family })),
|
|
583
|
+
};
|
|
584
|
+
} catch (err) {
|
|
585
|
+
lastError = err;
|
|
586
|
+
if (!isTransientDnsError(err)) break;
|
|
543
587
|
}
|
|
544
588
|
}
|
|
545
|
-
addresses.sort((a, b) => (a.family === 4 ? 0 : 1) - (b.family === 4 ? 0 : 1));
|
|
546
|
-
const first = addresses[0];
|
|
547
589
|
return {
|
|
548
|
-
ok:
|
|
549
|
-
reason: ""
|
|
550
|
-
ip:
|
|
551
|
-
family:
|
|
552
|
-
alternates: addresses.slice(1).map((entry) => ({ ip: entry.address, family: entry.family })),
|
|
590
|
+
ok: false,
|
|
591
|
+
reason: lastError === null ? "Failed to resolve host: cancelled" : `Failed to resolve host: ${lastError}`,
|
|
592
|
+
ip: "",
|
|
593
|
+
family: 0,
|
|
553
594
|
};
|
|
554
595
|
}
|
|
555
596
|
function budgetExceededResult(
|