pi-unsloth-webtools 0.4.0 → 0.4.2
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/engines.ts +39 -2
- package/package.json +1 -1
- package/pdf.ts +1 -1
- package/web-access.ts +2 -2
- package/web-fetch.ts +73 -26
- package/web-search.ts +11 -6
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/engines.ts
CHANGED
|
@@ -216,6 +216,10 @@ function parsePredExpr(input: string): Pred {
|
|
|
216
216
|
pos += "position()=last()".length;
|
|
217
217
|
return { op: "last" };
|
|
218
218
|
}
|
|
219
|
+
if (input.startsWith("last()", pos)) {
|
|
220
|
+
pos += "last()".length;
|
|
221
|
+
return { op: "last" };
|
|
222
|
+
}
|
|
219
223
|
if (input.startsWith("contains(@class,", pos)) {
|
|
220
224
|
pos += "contains(@class,".length;
|
|
221
225
|
const value = quoted();
|
|
@@ -359,7 +363,38 @@ function matchesPred(pred: Pred, el: DomNode, index: number, total: number): boo
|
|
|
359
363
|
}
|
|
360
364
|
}
|
|
361
365
|
|
|
366
|
+
function predContainsLast(pred: Pred): boolean {
|
|
367
|
+
switch (pred.op) {
|
|
368
|
+
case "last":
|
|
369
|
+
return true;
|
|
370
|
+
case "or":
|
|
371
|
+
case "and":
|
|
372
|
+
return predContainsLast(pred.a) || predContainsLast(pred.b);
|
|
373
|
+
case "child":
|
|
374
|
+
return pred.preds.some(predContainsLast);
|
|
375
|
+
default:
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
362
380
|
function applyStep(step: XStep, nodes: DomNode[]): DomNode[] {
|
|
381
|
+
if (step.preds.some(predContainsLast)) {
|
|
382
|
+
const out: DomNode[] = [];
|
|
383
|
+
const seen = new Set<DomNode>();
|
|
384
|
+
for (const node of nodes) {
|
|
385
|
+
const source = step.axis === "child" ? node.children : descendantsOf(node);
|
|
386
|
+
const list = source.filter((c) => !step.name || c.tag === step.name);
|
|
387
|
+
const total = list.length;
|
|
388
|
+
list.forEach((el, index) => {
|
|
389
|
+
if (seen.has(el)) return;
|
|
390
|
+
if (step.preds.every((p) => matchesPred(p, el, index, total))) {
|
|
391
|
+
seen.add(el);
|
|
392
|
+
out.push(el);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
363
398
|
const candidates: DomNode[] = [];
|
|
364
399
|
for (const node of nodes) {
|
|
365
400
|
const list = step.axis === "child" ? node.children : descendantsOf(node);
|
|
@@ -427,6 +462,7 @@ export function extractResults(
|
|
|
427
462
|
if (!data) continue;
|
|
428
463
|
result[key] = key === "href" ? normalizeUrl(data) : normalizeText(data);
|
|
429
464
|
}
|
|
465
|
+
if (!result.title && !result.href && !result.body) continue;
|
|
430
466
|
results.push(result);
|
|
431
467
|
}
|
|
432
468
|
return results;
|
|
@@ -445,8 +481,7 @@ function googleUserAgent(): string {
|
|
|
445
481
|
return (
|
|
446
482
|
`Mozilla/5.0 (Linux; Android ${androidVer}; ${device}) ` +
|
|
447
483
|
`AppleWebKit/537.36 (KHTML, like Gecko) ` +
|
|
448
|
-
`Chrome/${chromeMajor}.0.${chromeBuild}.${chromePatch} Mobile Safari/537.36`
|
|
449
|
-
"NSTNWV"
|
|
484
|
+
`Chrome/${chromeMajor}.0.${chromeBuild}.${chromePatch} Mobile Safari/537.36`
|
|
450
485
|
);
|
|
451
486
|
}
|
|
452
487
|
|
|
@@ -794,7 +829,9 @@ export class ResultsAggregator {
|
|
|
794
829
|
}
|
|
795
830
|
|
|
796
831
|
append(item: SearchResult): void {
|
|
832
|
+
if (typeof item.href !== "string" || !item.href.trim()) return;
|
|
797
833
|
const key = canonicalizeHref(item.href);
|
|
834
|
+
if (!key) return;
|
|
798
835
|
const existing = this.cache.get(key);
|
|
799
836
|
if (!existing || item.body.length > existing.body.length) {
|
|
800
837
|
this.cache.set(key, { ...item, href: key });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-unsloth-webtools",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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/pdf.ts
CHANGED
|
@@ -344,7 +344,7 @@ function findLink(links: LinkInfo[], span: SpanData): string | null {
|
|
|
344
344
|
if (midX < link.x0 || midX > link.x1 || midY < link.y0 || midY > link.y1) continue;
|
|
345
345
|
let uri = link.uri;
|
|
346
346
|
for (const c of "()\n") {
|
|
347
|
-
uri = uri.replaceAll(c, "%
|
|
347
|
+
uri = uri.replaceAll(c, "%" + c.charCodeAt(0).toString(16).padStart(2, "0"));
|
|
348
348
|
}
|
|
349
349
|
return `[${span.text.trim()}](${uri})`;
|
|
350
350
|
}
|
package/web-access.ts
CHANGED
|
@@ -137,7 +137,7 @@ export function normalizeWebsitePolicy(value: unknown): WebsitePolicy {
|
|
|
137
137
|
}
|
|
138
138
|
const normalized: WebsitePolicy = { allowedDomains: [], blockedDomains: [] };
|
|
139
139
|
for (const key of ["allowedDomains", "blockedDomains"] as const) {
|
|
140
|
-
const rawDomains = raw[key];
|
|
140
|
+
const rawDomains = raw[key] ?? [];
|
|
141
141
|
if (!Array.isArray(rawDomains)) throw new Error(`${key} must be a list`);
|
|
142
142
|
if (rawDomains.length > MAX_DOMAINS_PER_LIST) {
|
|
143
143
|
throw new Error(`${key} supports at most ${MAX_DOMAINS_PER_LIST} domains`);
|
|
@@ -408,7 +408,7 @@ export function isPublicIp(ip: string): boolean {
|
|
|
408
408
|
}
|
|
409
409
|
if (canonical === "::1" || canonical.startsWith("::")) return false;
|
|
410
410
|
if (canonical.startsWith("fc") || canonical.startsWith("fd")) return false;
|
|
411
|
-
if (/^fe[
|
|
411
|
+
if (/^fe[89a-f][0-9a-f]:/.test(canonical)) return false;
|
|
412
412
|
if (canonical.startsWith("ff")) return false;
|
|
413
413
|
if (canonical.startsWith("2001:db8")) return false;
|
|
414
414
|
if (canonical.startsWith("64:ff9b:")) return false;
|
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(
|
|
@@ -769,16 +810,22 @@ export async function fetchUrlRaw(
|
|
|
769
810
|
const performRequest = seams.request ?? requestHop;
|
|
770
811
|
const resolveWithBudget = async (hostname: string): Promise<ResolvedHost> => {
|
|
771
812
|
const abortController = new AbortController();
|
|
772
|
-
const
|
|
813
|
+
const waitMs = clampedRemainingMs(deadline, now);
|
|
814
|
+
const timeoutId = setTimeout(() => abortController.abort(), waitMs);
|
|
773
815
|
const resolveSignal = signal ? AbortSignal.any([signal, abortController.signal]) : abortController.signal;
|
|
816
|
+
let deadlineId: ReturnType<typeof setTimeout> | undefined;
|
|
817
|
+
const deadlinePromise = new Promise<ResolvedHost>((resolve) => {
|
|
818
|
+
deadlineId = setTimeout(() => resolve({ ok: false, reason: FETCH_TIMEOUT_MESSAGE, ip: "", family: 0 }), waitMs);
|
|
819
|
+
});
|
|
774
820
|
try {
|
|
775
|
-
const resolved = await resolveHost(hostname, resolveSignal);
|
|
821
|
+
const resolved = await Promise.race([resolveHost(hostname, resolveSignal), deadlinePromise]);
|
|
776
822
|
if (resolved.ok) return resolved;
|
|
777
823
|
if (signal?.aborted) return { ...resolved, reason: FETCH_CANCELLED_MESSAGE };
|
|
778
824
|
if (resolveSignal.aborted) return { ...resolved, reason: FETCH_TIMEOUT_MESSAGE };
|
|
779
825
|
return resolved;
|
|
780
826
|
} finally {
|
|
781
827
|
clearTimeout(timeoutId);
|
|
828
|
+
if (deadlineId !== undefined) clearTimeout(deadlineId);
|
|
782
829
|
}
|
|
783
830
|
};
|
|
784
831
|
const checkBudget = (contentType = ""): RawFetchResult | null => budgetExceededResult(deadline, signal, now, contentType);
|
package/web-search.ts
CHANGED
|
@@ -23,15 +23,16 @@ export type SearchClient = (
|
|
|
23
23
|
query: string,
|
|
24
24
|
maxResults: number,
|
|
25
25
|
signal?: AbortSignal,
|
|
26
|
+
timeoutMs?: number,
|
|
26
27
|
) => Promise<SearchResult[]>;
|
|
27
|
-
|
|
28
28
|
export async function ddgSearch(
|
|
29
29
|
query: string,
|
|
30
30
|
maxResults = MAX_RESULTS,
|
|
31
31
|
signal?: AbortSignal,
|
|
32
|
+
timeoutMs = SEARCH_TIMEOUT_MS,
|
|
32
33
|
): Promise<SearchResult[]> {
|
|
33
34
|
if (signal?.aborted) throw new SearchCancelled();
|
|
34
|
-
return autoTextSearch(query, maxResults,
|
|
35
|
+
return autoTextSearch(query, maxResults, timeoutMs, signal);
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
const POLICY_OVERFETCH = 4;
|
|
@@ -51,8 +52,11 @@ export async function webSearch(
|
|
|
51
52
|
query: string | undefined,
|
|
52
53
|
options: WebSearchOptions = {},
|
|
53
54
|
): Promise<string> {
|
|
54
|
-
const
|
|
55
|
-
const
|
|
55
|
+
const rawMaxResults = options.maxResults ?? (await loadDefaultMaxResults(options.cwd));
|
|
56
|
+
const saneMax = Number.isFinite(rawMaxResults) ? Math.floor(rawMaxResults as number) : MAX_RESULTS;
|
|
57
|
+
const maxResults = Math.min(20, Math.max(1, saneMax));
|
|
58
|
+
const rawTimeout = options.timeoutMs ?? SEARCH_TIMEOUT_MS;
|
|
59
|
+
const timeoutMs = Number.isFinite(rawTimeout) ? (rawTimeout as number) : SEARCH_TIMEOUT_MS;
|
|
56
60
|
const signal = options.signal;
|
|
57
61
|
const policy = options.websitePolicy ?? null;
|
|
58
62
|
const client = options.client ?? ddgSearch;
|
|
@@ -66,14 +70,15 @@ export async function webSearch(
|
|
|
66
70
|
(policy?.blockedDomains?.length ?? 0) > 0,
|
|
67
71
|
);
|
|
68
72
|
const wanted = restricted ? maxResults * POLICY_OVERFETCH : maxResults;
|
|
69
|
-
const results = await client(effectiveQuery, wanted, signal);
|
|
73
|
+
const results = await client(effectiveQuery, wanted, signal, timeoutMs);
|
|
70
74
|
if (signal?.aborted) return "Search cancelled.";
|
|
71
75
|
if (!results.length) return EMPTY_SEARCH_RESULTS[0];
|
|
72
76
|
const allowed: SearchResult[] = [];
|
|
73
77
|
for (const result of results) {
|
|
74
78
|
if (allowed.length >= maxResults) break;
|
|
75
79
|
const href = String(result.href ?? "").trim();
|
|
76
|
-
if (
|
|
80
|
+
if (!href) continue;
|
|
81
|
+
if (!checkUrlAccess(href, policy)[0]) continue;
|
|
77
82
|
allowed.push(result);
|
|
78
83
|
}
|
|
79
84
|
if (!allowed.length) return EMPTY_SEARCH_RESULTS[1];
|