pi-unsloth-webtools 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -3
- package/agent-dir.ts +19 -0
- package/cache.ts +154 -0
- package/engines.ts +70 -8
- package/html-to-md.ts +12 -3
- package/index.ts +24 -6
- package/package.json +4 -1
- package/pdf.ts +1 -1
- package/settings.ts +114 -0
- package/web-access.ts +48 -27
- package/web-fetch.ts +246 -82
- package/web-search.ts +7 -1
package/web-fetch.ts
CHANGED
|
@@ -2,20 +2,24 @@ import { lookup as dnsLookup } from "node:dns/promises";
|
|
|
2
2
|
import type { LookupAllOptions } from "node:dns";
|
|
3
3
|
import http from "node:http";
|
|
4
4
|
import https from "node:https";
|
|
5
|
+
|
|
5
6
|
import { createBrotliDecompress, createGunzip, createInflate, createInflateRaw } from "node:zlib";
|
|
6
7
|
import type { IncomingMessage } from "node:http";
|
|
7
8
|
import type { Transform } from "node:stream";
|
|
8
9
|
import {
|
|
9
10
|
checkUrlAccess,
|
|
11
|
+
githubRawContentUrl,
|
|
10
12
|
githubRepoRawReadmeUrl,
|
|
11
13
|
githubRepoReadmeApiUrl,
|
|
12
14
|
isPublicIp,
|
|
15
|
+
MAX_SIGNAL_TIMEOUT_MS,
|
|
13
16
|
normalizeUrlScheme,
|
|
14
17
|
type WebsitePolicy,
|
|
15
18
|
} from "./web-access.ts";
|
|
16
19
|
import { collapseWhitespace, decodeHtmlEntities, feedHtml, htmlToMarkdown } from "./html-to-md.ts";
|
|
17
20
|
import type { AttrDict } from "./html-to-md.ts";
|
|
18
21
|
import { INVALID_CHARREFS } from "./entities.ts";
|
|
22
|
+
import { getCached, isFresh, setCached, staleNotice } from "./cache.ts";
|
|
19
23
|
import { extractPdfText } from "./pdf.ts";
|
|
20
24
|
import { randomUserAgent } from "./user-agents.ts";
|
|
21
25
|
|
|
@@ -24,8 +28,23 @@ const MAX_DECOMPRESSED_BYTES = 64 * 1024 * 1024;
|
|
|
24
28
|
const META_VALUE_MAX_CHARS = 300;
|
|
25
29
|
const MAX_PDF_FETCH_BYTES = 10 * 1024 * 1024;
|
|
26
30
|
const MAX_REQUESTS = 5;
|
|
27
|
-
const MAX_SIGNAL_TIMEOUT_MS = 2 ** 31 - 1;
|
|
28
31
|
export const DEFAULT_FETCH_TIMEOUT_MS = 60_000;
|
|
32
|
+
export { loadDefaultFetchMaxChars, loadDefaultFetchTimeoutMs, loadDefaultFetchSettings } from "./settings.ts";
|
|
33
|
+
function remainingMs(deadline: number, now: () => number): number {
|
|
34
|
+
return Math.max(1, deadline - now());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function clampedRemainingMs(deadline: number, now: () => number): number {
|
|
38
|
+
return Math.min(MAX_SIGNAL_TIMEOUT_MS, remainingMs(deadline, now));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function withTruncation(text: string, truncated: boolean): string {
|
|
42
|
+
return truncated ? text + TRUNCATED_BODY_NOTICE : text;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function preserveTruncationNotice(original: string, converted: string): string {
|
|
46
|
+
return original.endsWith(TRUNCATED_BODY_SUFFIX) && !converted.endsWith(TRUNCATED_BODY_SUFFIX) ? converted + TRUNCATED_BODY_NOTICE : converted;
|
|
47
|
+
}
|
|
29
48
|
|
|
30
49
|
const UTF32_LE_BOM = Buffer.from([0xff, 0xfe, 0x00, 0x00]);
|
|
31
50
|
const UTF32_BE_BOM = Buffer.from([0x00, 0x00, 0xfe, 0xff]);
|
|
@@ -120,8 +139,8 @@ export class FetchTimeoutError extends Error {
|
|
|
120
139
|
}
|
|
121
140
|
const FETCH_CANCELLED_MESSAGE = "Failed to fetch URL: cancelled.";
|
|
122
141
|
const FETCH_TIMEOUT_MESSAGE = "Failed to fetch URL: timed out.";
|
|
123
|
-
const TRUNCATED_BODY_NOTICE = "\n\n... (page truncated at the download limit)";
|
|
124
142
|
const TRUNCATED_BODY_SUFFIX = "... (page truncated at the download limit)";
|
|
143
|
+
const TRUNCATED_BODY_NOTICE = "\n\n" + TRUNCATED_BODY_SUFFIX;
|
|
125
144
|
|
|
126
145
|
function fetchErrorMessage(err: unknown): string {
|
|
127
146
|
if (err instanceof FetchCancelledError) return FETCH_CANCELLED_MESSAGE;
|
|
@@ -132,13 +151,13 @@ function fetchErrorMessage(err: unknown): string {
|
|
|
132
151
|
return `Failed to fetch URL: ${message}`;
|
|
133
152
|
}
|
|
134
153
|
|
|
154
|
+
function emptyResult(error: string, contentType = ""): RawFetchResult {
|
|
155
|
+
return { error, body: "", contentType };
|
|
156
|
+
}
|
|
157
|
+
|
|
135
158
|
function statusErrorResult(status: number): RawFetchResult {
|
|
136
159
|
const reason = http.STATUS_CODES[status] ?? "";
|
|
137
|
-
return {
|
|
138
|
-
error: `Failed to fetch URL: HTTP ${status}${reason ? ` ${reason}` : ""}`,
|
|
139
|
-
body: "",
|
|
140
|
-
contentType: "",
|
|
141
|
-
};
|
|
160
|
+
return emptyResult(`Failed to fetch URL: HTTP ${status}${reason ? ` ${reason}` : ""}`);
|
|
142
161
|
}
|
|
143
162
|
|
|
144
163
|
export interface FetchPageOptions {
|
|
@@ -374,6 +393,41 @@ function sniffMetaCharsetForHtml(bytes: Buffer, contentType: string): string | n
|
|
|
374
393
|
}
|
|
375
394
|
return sniffMetaCharset(bytes);
|
|
376
395
|
}
|
|
396
|
+
function extractMetaRefresh(html: string): string | null {
|
|
397
|
+
let refresh: string | null = null;
|
|
398
|
+
const handle = (name: string, attrs: AttrDict) => {
|
|
399
|
+
if (refresh !== null) return;
|
|
400
|
+
if (name !== "meta") return;
|
|
401
|
+
const equiv = (attrs["http-equiv"] ?? "").toLowerCase();
|
|
402
|
+
if (equiv !== "refresh") return;
|
|
403
|
+
const content = attrs["content"] ?? "";
|
|
404
|
+
const match = /;\s*url\s*=\s*(.+)/i.exec(content);
|
|
405
|
+
if (!match) return;
|
|
406
|
+
let url = match[1].trim();
|
|
407
|
+
if ((url.startsWith('"') && url.endsWith('"')) || (url.startsWith("'") && url.endsWith("'"))) url = url.slice(1, -1);
|
|
408
|
+
url = url.trim();
|
|
409
|
+
if (url) refresh = url;
|
|
410
|
+
};
|
|
411
|
+
feedHtml(html, {
|
|
412
|
+
handleStartTag: handle,
|
|
413
|
+
handleStartEndTag: handle,
|
|
414
|
+
handleEndTag() {},
|
|
415
|
+
handleData() {},
|
|
416
|
+
handleEntityRef() {},
|
|
417
|
+
handleCharRef() {},
|
|
418
|
+
});
|
|
419
|
+
return refresh;
|
|
420
|
+
}
|
|
421
|
+
function metaRefreshUrl(html: string, base: string): string | null {
|
|
422
|
+
const target = extractMetaRefresh(html);
|
|
423
|
+
if (!target) return null;
|
|
424
|
+
try {
|
|
425
|
+
const next = new URL(target, base).toString();
|
|
426
|
+
return next !== base ? next : null;
|
|
427
|
+
} catch {
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
377
431
|
|
|
378
432
|
function decodeUtf32(bytes: Buffer, littleEndian: boolean): string {
|
|
379
433
|
let out = "";
|
|
@@ -498,16 +552,14 @@ async function resolveAndValidate(hostname: string, signal?: AbortSignal): Promi
|
|
|
498
552
|
alternates: addresses.slice(1).map((entry) => ({ ip: entry.address, family: entry.family })),
|
|
499
553
|
};
|
|
500
554
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
555
|
function budgetExceededResult(
|
|
504
556
|
deadline: number | null,
|
|
505
557
|
signal: AbortSignal | undefined,
|
|
506
558
|
now: () => number = Date.now,
|
|
507
559
|
contentType = "",
|
|
508
560
|
): RawFetchResult | null {
|
|
509
|
-
if (signal?.aborted) return
|
|
510
|
-
if (deadline !== null && now() >= deadline) return
|
|
561
|
+
if (signal?.aborted) return emptyResult(FETCH_CANCELLED_MESSAGE, contentType);
|
|
562
|
+
if (deadline !== null && now() >= deadline) return emptyResult(FETCH_TIMEOUT_MESSAGE, contentType);
|
|
511
563
|
return null;
|
|
512
564
|
}
|
|
513
565
|
|
|
@@ -542,6 +594,8 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
|
|
|
542
594
|
callback(null, [{ address: opts.pinnedIp, family: opts.family }]),
|
|
543
595
|
};
|
|
544
596
|
let settled = false;
|
|
597
|
+
let resRef: IncomingMessage | null = null;
|
|
598
|
+
let decoderRef: Transform | null = null;
|
|
545
599
|
const settle = (action: () => void) => {
|
|
546
600
|
if (settled) return;
|
|
547
601
|
settled = true;
|
|
@@ -549,6 +603,7 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
|
|
|
549
603
|
action();
|
|
550
604
|
};
|
|
551
605
|
const request = transport.request(options, (res: IncomingMessage) => {
|
|
606
|
+
resRef = res;
|
|
552
607
|
const codec = contentEncodingCodec(res.headers["content-encoding"]);
|
|
553
608
|
const chunks: Buffer[] = [];
|
|
554
609
|
let total = 0;
|
|
@@ -625,6 +680,8 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
|
|
|
625
680
|
let rawFallbackTried = false;
|
|
626
681
|
let resEnded = false;
|
|
627
682
|
const wire = (d: Transform) => {
|
|
683
|
+
decoderRef = d;
|
|
684
|
+
decoder = d;
|
|
628
685
|
d.on("data", (chunk: Buffer) => {
|
|
629
686
|
outputStarted = true;
|
|
630
687
|
acceptData(chunk);
|
|
@@ -642,6 +699,7 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
|
|
|
642
699
|
rawFallbackTried = true;
|
|
643
700
|
d.destroy();
|
|
644
701
|
decoder = createInflateRaw({ maxOutputLength: MAX_DECOMPRESSED_BYTES });
|
|
702
|
+
decoderRef = decoder;
|
|
645
703
|
wire(decoder);
|
|
646
704
|
for (const buffered of rawBuffer) decoder.write(buffered);
|
|
647
705
|
if (resEnded) decoder.end();
|
|
@@ -656,6 +714,7 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
|
|
|
656
714
|
});
|
|
657
715
|
};
|
|
658
716
|
decoder = createDecodeStream(codec);
|
|
717
|
+
decoderRef = decoder;
|
|
659
718
|
wire(decoder);
|
|
660
719
|
res.on("data", (chunk: Buffer) => {
|
|
661
720
|
if (settled) return;
|
|
@@ -680,7 +739,11 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
|
|
|
680
739
|
finish(err.message, Buffer.concat(chunks));
|
|
681
740
|
});
|
|
682
741
|
});
|
|
683
|
-
const onAbort = () =>
|
|
742
|
+
const onAbort = () => {
|
|
743
|
+
decoderRef?.destroy();
|
|
744
|
+
resRef?.destroy();
|
|
745
|
+
request.destroy(new FetchCancelledError());
|
|
746
|
+
};
|
|
684
747
|
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
685
748
|
request.on("timeout", () => request.destroy(new FetchTimeoutError()));
|
|
686
749
|
request.on("error", (err) => settle(() => reject(err)));
|
|
@@ -705,23 +768,27 @@ export async function fetchUrlRaw(
|
|
|
705
768
|
const resolveHost = seams.resolve ?? resolveAndValidate;
|
|
706
769
|
const performRequest = seams.request ?? requestHop;
|
|
707
770
|
const resolveWithBudget = async (hostname: string): Promise<ResolvedHost> => {
|
|
708
|
-
const
|
|
709
|
-
const
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
771
|
+
const abortController = new AbortController();
|
|
772
|
+
const timeoutId = setTimeout(() => abortController.abort(), clampedRemainingMs(deadline, now));
|
|
773
|
+
const resolveSignal = signal ? AbortSignal.any([signal, abortController.signal]) : abortController.signal;
|
|
774
|
+
try {
|
|
775
|
+
const resolved = await resolveHost(hostname, resolveSignal);
|
|
776
|
+
if (resolved.ok) return resolved;
|
|
777
|
+
if (signal?.aborted) return { ...resolved, reason: FETCH_CANCELLED_MESSAGE };
|
|
778
|
+
if (resolveSignal.aborted) return { ...resolved, reason: FETCH_TIMEOUT_MESSAGE };
|
|
779
|
+
return resolved;
|
|
780
|
+
} finally {
|
|
781
|
+
clearTimeout(timeoutId);
|
|
782
|
+
}
|
|
715
783
|
};
|
|
716
|
-
|
|
784
|
+
const checkBudget = (contentType = ""): RawFetchResult | null => budgetExceededResult(deadline, signal, now, contentType);
|
|
717
785
|
url = normalizeUrlScheme(url);
|
|
718
786
|
const [allowed, reason, hostname] = checkUrlAccess(url, policy);
|
|
719
|
-
if (!allowed) return
|
|
720
|
-
|
|
721
|
-
const budgetResult = budgetExceededResult(deadline, signal, now);
|
|
787
|
+
if (!allowed) return emptyResult(reason);
|
|
788
|
+
const budgetResult = checkBudget();
|
|
722
789
|
if (budgetResult !== null) return budgetResult;
|
|
723
790
|
let resolved = await resolveWithBudget(hostname);
|
|
724
|
-
if (!resolved.ok) return
|
|
791
|
+
if (!resolved.ok) return emptyResult(resolved.reason);
|
|
725
792
|
|
|
726
793
|
let currentUrl = url;
|
|
727
794
|
let pinnedIp = resolved.ip;
|
|
@@ -730,7 +797,7 @@ export async function fetchUrlRaw(
|
|
|
730
797
|
let alternateIndex = 0;
|
|
731
798
|
const userAgent = randomUserAgent();
|
|
732
799
|
for (let hop = 0; hop < MAX_REQUESTS; hop++) {
|
|
733
|
-
const budgetResult =
|
|
800
|
+
const budgetResult = checkBudget();
|
|
734
801
|
if (budgetResult !== null) return budgetResult;
|
|
735
802
|
const parsed = new URL(currentUrl);
|
|
736
803
|
const hostHeader = parsed.hostname + (parsed.port ? `:${parsed.port}` : "");
|
|
@@ -741,7 +808,7 @@ export async function fetchUrlRaw(
|
|
|
741
808
|
"Accept-Encoding": "identity",
|
|
742
809
|
};
|
|
743
810
|
if (options.extraHeaders) Object.assign(headers, options.extraHeaders);
|
|
744
|
-
const inactivity =
|
|
811
|
+
const inactivity = remainingMs(deadline, now);
|
|
745
812
|
let response: HopResponse;
|
|
746
813
|
try {
|
|
747
814
|
response = await performRequest({
|
|
@@ -767,7 +834,7 @@ export async function fetchUrlRaw(
|
|
|
767
834
|
pinnedFamily = next.family;
|
|
768
835
|
continue;
|
|
769
836
|
}
|
|
770
|
-
return
|
|
837
|
+
return emptyResult(fetchErrorMessage(err));
|
|
771
838
|
}
|
|
772
839
|
|
|
773
840
|
if (response.status >= 300 && response.status < 400) {
|
|
@@ -777,24 +844,20 @@ export async function fetchUrlRaw(
|
|
|
777
844
|
const rawLocation = response.headers.location;
|
|
778
845
|
const location = Array.isArray(rawLocation) ? rawLocation[0] : rawLocation;
|
|
779
846
|
if (!location) {
|
|
780
|
-
return
|
|
781
|
-
error: "Failed to fetch URL: the redirect is missing a Location header.",
|
|
782
|
-
body: "",
|
|
783
|
-
contentType: "",
|
|
784
|
-
};
|
|
847
|
+
return emptyResult("Failed to fetch URL: the redirect is missing a Location header.");
|
|
785
848
|
}
|
|
786
849
|
try {
|
|
787
850
|
currentUrl = new URL(location, currentUrl).toString();
|
|
788
851
|
} catch {
|
|
789
|
-
return
|
|
852
|
+
return emptyResult("Failed to fetch URL: the redirect has an invalid Location.");
|
|
790
853
|
}
|
|
791
854
|
const [redirectAllowed, redirectReason, redirectHost] = checkUrlAccess(
|
|
792
855
|
currentUrl,
|
|
793
856
|
policy,
|
|
794
857
|
);
|
|
795
|
-
if (!redirectAllowed) return
|
|
858
|
+
if (!redirectAllowed) return emptyResult(redirectReason);
|
|
796
859
|
const redirected = await resolveWithBudget(redirectHost);
|
|
797
|
-
if (!redirected.ok) return
|
|
860
|
+
if (!redirected.ok) return emptyResult(redirected.reason);
|
|
798
861
|
pinnedIp = redirected.ip;
|
|
799
862
|
pinnedFamily = redirected.family;
|
|
800
863
|
alternates = redirected.alternates ?? [];
|
|
@@ -802,7 +865,7 @@ export async function fetchUrlRaw(
|
|
|
802
865
|
continue;
|
|
803
866
|
}
|
|
804
867
|
|
|
805
|
-
const postBudgetResult =
|
|
868
|
+
const postBudgetResult = checkBudget();
|
|
806
869
|
if (postBudgetResult !== null) return postBudgetResult;
|
|
807
870
|
|
|
808
871
|
if (response.status >= 400) {
|
|
@@ -829,46 +892,59 @@ export async function fetchUrlRaw(
|
|
|
829
892
|
try {
|
|
830
893
|
pdfText = await extractPdfText(response.body);
|
|
831
894
|
} catch {
|
|
832
|
-
return
|
|
833
|
-
|
|
895
|
+
return emptyResult(
|
|
896
|
+
response.truncated
|
|
834
897
|
? "(PDF content could not be read as text; the download was truncated at the download limit)"
|
|
835
898
|
: "(PDF content could not be read as text)",
|
|
836
|
-
body: "",
|
|
837
899
|
contentType,
|
|
838
|
-
|
|
900
|
+
);
|
|
839
901
|
}
|
|
840
|
-
const budgetResult =
|
|
902
|
+
const budgetResult = checkBudget(contentType);
|
|
841
903
|
if (budgetResult !== null) return budgetResult;
|
|
842
904
|
if (!pdfText) pdfText = "(PDF contains no extractable text)";
|
|
843
|
-
|
|
905
|
+
pdfText = withTruncation(pdfText, Boolean(response.truncated));
|
|
844
906
|
return { error: null, body: pdfText, contentType: "application/pdf" };
|
|
845
907
|
}
|
|
846
908
|
|
|
847
909
|
if (!isTextCandidateContentType(contentType)) {
|
|
848
910
|
const safeType = parseContentType(contentType) || "unknown type";
|
|
849
|
-
return
|
|
850
|
-
|
|
851
|
-
body: "",
|
|
911
|
+
return emptyResult(
|
|
912
|
+
`(non-text content: ${safeType}, ${response.body.length} bytes; not readable as text)`,
|
|
852
913
|
contentType,
|
|
853
|
-
|
|
914
|
+
);
|
|
854
915
|
}
|
|
855
916
|
|
|
856
917
|
if (hasBinaryMagic(response.body)) {
|
|
857
|
-
return
|
|
858
|
-
|
|
859
|
-
body: "",
|
|
918
|
+
return emptyResult(
|
|
919
|
+
`(binary content, ${response.body.length} bytes; not readable as text)`,
|
|
860
920
|
contentType,
|
|
861
|
-
|
|
921
|
+
);
|
|
862
922
|
}
|
|
863
923
|
|
|
864
924
|
const declaredCodec = declaredCharset ? normalizeCharset(declaredCharset) : null;
|
|
865
925
|
const bomCodec = bomCodecFor(response.body);
|
|
866
|
-
const rawHtml =
|
|
926
|
+
const rawHtml = withTruncation(
|
|
867
927
|
decodeWithCodec(
|
|
868
928
|
response.body,
|
|
869
929
|
bomCodec ?? declaredCodec ?? sniffMetaCharsetForHtml(response.body, contentType) ?? "utf-8",
|
|
870
|
-
)
|
|
871
|
-
|
|
930
|
+
),
|
|
931
|
+
Boolean(response.truncated),
|
|
932
|
+
);
|
|
933
|
+
const nextUrl = isHtmlContent(rawHtml, contentType) ? metaRefreshUrl(rawHtml, currentUrl) : null;
|
|
934
|
+
if (nextUrl) {
|
|
935
|
+
const [refreshAllowed, refreshReason, refreshHost] = checkUrlAccess(nextUrl, policy);
|
|
936
|
+
if (!refreshAllowed) return emptyResult(refreshReason);
|
|
937
|
+
const refreshBudget = checkBudget();
|
|
938
|
+
if (refreshBudget !== null) return refreshBudget;
|
|
939
|
+
const redirected = await resolveWithBudget(refreshHost);
|
|
940
|
+
if (!redirected.ok) return emptyResult(redirected.reason);
|
|
941
|
+
currentUrl = nextUrl;
|
|
942
|
+
pinnedIp = redirected.ip;
|
|
943
|
+
pinnedFamily = redirected.family;
|
|
944
|
+
alternates = redirected.alternates ?? [];
|
|
945
|
+
alternateIndex = 0;
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
872
948
|
if (looksBinary(rawHtml)) {
|
|
873
949
|
let alt: string | null = null;
|
|
874
950
|
if (
|
|
@@ -879,19 +955,18 @@ export async function fetchUrlRaw(
|
|
|
879
955
|
if (!looksBinary(candidate)) alt = candidate;
|
|
880
956
|
}
|
|
881
957
|
if (alt !== null) {
|
|
882
|
-
|
|
958
|
+
alt = withTruncation(alt, Boolean(response.truncated));
|
|
883
959
|
return { error: null, body: alt, contentType };
|
|
884
960
|
}
|
|
885
|
-
return
|
|
886
|
-
|
|
887
|
-
body: "",
|
|
961
|
+
return emptyResult(
|
|
962
|
+
`(binary content, ${response.body.length} bytes; not readable as text)`,
|
|
888
963
|
contentType,
|
|
889
|
-
|
|
964
|
+
);
|
|
890
965
|
}
|
|
891
966
|
|
|
892
967
|
return { error: null, body: rawHtml, contentType };
|
|
893
968
|
}
|
|
894
|
-
return
|
|
969
|
+
return emptyResult("Failed to fetch URL: too many redirects.");
|
|
895
970
|
}
|
|
896
971
|
|
|
897
972
|
function bomCodecFor(bytes: Buffer): string | null {
|
|
@@ -983,8 +1058,14 @@ const META_KEYS: Record<string, keyof PageMeta> = {
|
|
|
983
1058
|
|
|
984
1059
|
function cutAtCharBoundary(text: string, maxChars: number): string {
|
|
985
1060
|
const sliced = text.slice(0, maxChars);
|
|
1061
|
+
if (!sliced) return sliced;
|
|
986
1062
|
const last = sliced.charCodeAt(sliced.length - 1);
|
|
987
|
-
|
|
1063
|
+
if (last >= 0xd800 && last <= 0xdbff) return sliced.slice(0, -1);
|
|
1064
|
+
if (last >= 0xdc00 && last <= 0xdfff) {
|
|
1065
|
+
const prev = sliced.length >= 2 ? sliced.charCodeAt(sliced.length - 2) : 0;
|
|
1066
|
+
if (prev < 0xd800 || prev > 0xdbff) return sliced.slice(0, -1);
|
|
1067
|
+
}
|
|
1068
|
+
return sliced;
|
|
988
1069
|
}
|
|
989
1070
|
|
|
990
1071
|
function capMetaValue(value: string): string {
|
|
@@ -1028,10 +1109,7 @@ function pagePrefixedMarkdown(html: string): string {
|
|
|
1028
1109
|
if (meta.site) lines.push(`Site: ${meta.site}`);
|
|
1029
1110
|
const markdown = htmlToMarkdown(html, true);
|
|
1030
1111
|
const converted = lines.length ? `${lines.join("\n")}\n\n${markdown}` : markdown;
|
|
1031
|
-
|
|
1032
|
-
return converted + TRUNCATED_BODY_NOTICE;
|
|
1033
|
-
}
|
|
1034
|
-
return converted;
|
|
1112
|
+
return preserveTruncationNotice(html, converted);
|
|
1035
1113
|
}
|
|
1036
1114
|
|
|
1037
1115
|
function formatReadmeBody(body: string): string {
|
|
@@ -1041,7 +1119,62 @@ function formatReadmeBody(body: string): string {
|
|
|
1041
1119
|
}
|
|
1042
1120
|
return body;
|
|
1043
1121
|
}
|
|
1044
|
-
|
|
1122
|
+
function isHtmlContent(body: string, contentType: string): boolean {
|
|
1123
|
+
return contentType.includes("html") || looksLikeHtml(body);
|
|
1124
|
+
}
|
|
1125
|
+
function renderBody(body: string, contentType: string): string {
|
|
1126
|
+
return isHtmlContent(body, contentType) ? pagePrefixedMarkdown(body) : body.trim();
|
|
1127
|
+
}
|
|
1128
|
+
async function persistCache(url: string, body: string, contentType: string, useCache: boolean): Promise<void> {
|
|
1129
|
+
if (!useCache) return;
|
|
1130
|
+
try {
|
|
1131
|
+
await setCached(url, body, contentType);
|
|
1132
|
+
} catch {}
|
|
1133
|
+
}
|
|
1134
|
+
function isHttp404Error(error: string): boolean {
|
|
1135
|
+
return /HTTP 404\b/.test(error);
|
|
1136
|
+
}
|
|
1137
|
+
async function fetchWaybackSnapshot(
|
|
1138
|
+
url: string,
|
|
1139
|
+
rawFetch: (url: string, options: RawFetchOptions) => Promise<RawFetchResult>,
|
|
1140
|
+
options: RawFetchOptions,
|
|
1141
|
+
deadlineMs: number,
|
|
1142
|
+
now: () => number,
|
|
1143
|
+
): Promise<{ body: string; contentType: string; timestamp: string } | null> {
|
|
1144
|
+
if (now() >= deadlineMs) return null;
|
|
1145
|
+
const availUrl = `https://archive.org/wayback/available?url=${encodeURIComponent(url)}`;
|
|
1146
|
+
let availResult: RawFetchResult | null = null;
|
|
1147
|
+
try {
|
|
1148
|
+
availResult = await rawFetch(availUrl, options);
|
|
1149
|
+
} catch {
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
if (!availResult || availResult.error !== null) return null;
|
|
1153
|
+
let snapshotUrl: string | null = null;
|
|
1154
|
+
let timestamp = "";
|
|
1155
|
+
try {
|
|
1156
|
+
const data = JSON.parse(availResult.body) as {
|
|
1157
|
+
archived_snapshots?: { closest?: { available?: boolean; url?: string; timestamp?: string; status?: string | number } };
|
|
1158
|
+
};
|
|
1159
|
+
const closest = data.archived_snapshots?.closest;
|
|
1160
|
+
if (closest?.available && closest.url && String(closest.status) === "200") {
|
|
1161
|
+
snapshotUrl = closest.url;
|
|
1162
|
+
timestamp = closest.timestamp ?? "";
|
|
1163
|
+
}
|
|
1164
|
+
} catch {
|
|
1165
|
+
return null;
|
|
1166
|
+
}
|
|
1167
|
+
if (!snapshotUrl) return null;
|
|
1168
|
+
if (now() >= deadlineMs) return null;
|
|
1169
|
+
let snapResult: RawFetchResult | null = null;
|
|
1170
|
+
try {
|
|
1171
|
+
snapResult = await rawFetch(snapshotUrl, options);
|
|
1172
|
+
} catch {
|
|
1173
|
+
return null;
|
|
1174
|
+
}
|
|
1175
|
+
if (!snapResult || snapResult.error !== null) return null;
|
|
1176
|
+
return { body: snapResult.body, contentType: snapResult.contentType, timestamp };
|
|
1177
|
+
}
|
|
1045
1178
|
export async function fetchPageText(
|
|
1046
1179
|
url: string,
|
|
1047
1180
|
options: FetchPageOptions = {},
|
|
@@ -1053,7 +1186,6 @@ export async function fetchPageText(
|
|
|
1053
1186
|
const policy = options.websitePolicy ?? null;
|
|
1054
1187
|
const maxChars = options.maxChars;
|
|
1055
1188
|
const rawFetch = options.rawFetch ?? fetchUrlRaw;
|
|
1056
|
-
|
|
1057
1189
|
url = normalizeUrlScheme(url);
|
|
1058
1190
|
const [allowed, reason] = checkUrlAccess(url, policy);
|
|
1059
1191
|
if (!allowed) return reason;
|
|
@@ -1065,7 +1197,16 @@ export async function fetchPageText(
|
|
|
1065
1197
|
maxPdfBytes: options.maxPdfBytes,
|
|
1066
1198
|
seams: options.seams,
|
|
1067
1199
|
};
|
|
1068
|
-
|
|
1200
|
+
const useCache = !options.seams && !options.rawFetch;
|
|
1201
|
+
const githubRaw = githubRawContentUrl(url);
|
|
1202
|
+
if (githubRaw) {
|
|
1203
|
+
const rawResult = await rawFetch(githubRaw, rawFetchOptions);
|
|
1204
|
+
if (rawResult.error === null) {
|
|
1205
|
+
const out = renderBody(rawResult.body, rawResult.contentType);
|
|
1206
|
+
await persistCache(url, rawResult.body, rawResult.contentType, useCache);
|
|
1207
|
+
return truncatePageText(out, maxChars);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1069
1210
|
const readmeApiUrl = githubRepoReadmeApiUrl(url);
|
|
1070
1211
|
if (readmeApiUrl) {
|
|
1071
1212
|
const readmeResult = await rawFetch(readmeApiUrl, {
|
|
@@ -1077,29 +1218,52 @@ export async function fetchPageText(
|
|
|
1077
1218
|
});
|
|
1078
1219
|
const apiBody = readmeResult.error === null ? formatReadmeBody(readmeResult.body) : "";
|
|
1079
1220
|
if (apiBody.trim()) {
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
);
|
|
1221
|
+
const rendered = `README of ${url} (fetched via the GitHub README API):\n\n` + apiBody;
|
|
1222
|
+
await persistCache(url, rendered, "text/markdown", useCache);
|
|
1223
|
+
return truncatePageText(rendered, maxChars);
|
|
1084
1224
|
}
|
|
1085
1225
|
const rawReadmeUrl = githubRepoRawReadmeUrl(url);
|
|
1086
1226
|
if (rawReadmeUrl) {
|
|
1087
1227
|
const rawResult = await rawFetch(rawReadmeUrl, rawFetchOptions);
|
|
1088
1228
|
const rawBody = rawResult.error === null ? formatReadmeBody(rawResult.body) : "";
|
|
1089
1229
|
if (rawBody.trim()) {
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
);
|
|
1230
|
+
const rendered = `README of ${url} (fetched via the GitHub raw README URL):\n\n` + rawBody;
|
|
1231
|
+
await persistCache(url, rendered, "text/markdown", useCache);
|
|
1232
|
+
return truncatePageText(rendered, maxChars);
|
|
1094
1233
|
}
|
|
1095
1234
|
}
|
|
1096
1235
|
}
|
|
1097
|
-
|
|
1236
|
+
const originalUrl = url;
|
|
1098
1237
|
const result = await rawFetch(url, rawFetchOptions);
|
|
1099
|
-
if (result.error !== null)
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1238
|
+
if (result.error !== null) {
|
|
1239
|
+
if (!result.error.startsWith("Blocked:")) {
|
|
1240
|
+
if (useCache) {
|
|
1241
|
+
try {
|
|
1242
|
+
const cached = await getCached(originalUrl);
|
|
1243
|
+
if (cached) {
|
|
1244
|
+
let cachedOut = renderBody(cached.body, cached.contentType);
|
|
1245
|
+
if (!isFresh(cached, now())) cachedOut += staleNotice(cached);
|
|
1246
|
+
else cachedOut += "\n\n*Served from cache — network fetch failed*";
|
|
1247
|
+
return truncatePageText(cachedOut, maxChars);
|
|
1248
|
+
}
|
|
1249
|
+
} catch {}
|
|
1250
|
+
}
|
|
1251
|
+
if (isHttp404Error(result.error)) {
|
|
1252
|
+
try {
|
|
1253
|
+
const wb = await fetchWaybackSnapshot(originalUrl, rawFetch, rawFetchOptions, deadlineMs, now);
|
|
1254
|
+
if (wb) {
|
|
1255
|
+
let out = renderBody(wb.body, wb.contentType);
|
|
1256
|
+
const ts = wb.timestamp ? `${wb.timestamp.slice(0, 4)}-${wb.timestamp.slice(4, 6)}-${wb.timestamp.slice(6, 8)}` : "unknown date";
|
|
1257
|
+
out = `*Fetched from Wayback Machine snapshot (${ts}) for ${originalUrl}:*\n\n` + out;
|
|
1258
|
+
await persistCache(originalUrl, wb.body, wb.contentType, useCache);
|
|
1259
|
+
return truncatePageText(out, maxChars);
|
|
1260
|
+
}
|
|
1261
|
+
} catch {}
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
return result.error;
|
|
1265
|
+
}
|
|
1266
|
+
const finalOut = renderBody(result.body, result.contentType);
|
|
1267
|
+
await persistCache(originalUrl, result.body, result.contentType, useCache);
|
|
1268
|
+
return truncatePageText(finalOut, maxChars);
|
|
1105
1269
|
}
|
package/web-search.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { checkUrlAccess, scopeSearchQuery, type WebsitePolicy } from "./web-access.ts";
|
|
2
|
+
import { loadDefaultMaxResults } from "./settings.ts";
|
|
2
3
|
import { collapseWhitespace } from "./html-to-md.ts";
|
|
3
4
|
import {
|
|
4
5
|
autoTextSearch,
|
|
@@ -41,13 +42,16 @@ export interface WebSearchOptions {
|
|
|
41
42
|
signal?: AbortSignal;
|
|
42
43
|
websitePolicy?: WebsitePolicy | null;
|
|
43
44
|
client?: SearchClient;
|
|
45
|
+
cwd?: string;
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
export { loadDefaultMaxResults };
|
|
49
|
+
|
|
46
50
|
export async function webSearch(
|
|
47
51
|
query: string | undefined,
|
|
48
52
|
options: WebSearchOptions = {},
|
|
49
53
|
): Promise<string> {
|
|
50
|
-
const maxResults = options.maxResults ??
|
|
54
|
+
const maxResults = options.maxResults ?? (await loadDefaultMaxResults(options.cwd));
|
|
51
55
|
const timeoutMs = options.timeoutMs ?? SEARCH_TIMEOUT_MS;
|
|
52
56
|
const signal = options.signal;
|
|
53
57
|
const policy = options.websitePolicy ?? null;
|
|
@@ -83,6 +87,8 @@ export async function webSearch(
|
|
|
83
87
|
export function searchFailureMessage(exc: unknown, timeoutMs = SEARCH_TIMEOUT_MS): string {
|
|
84
88
|
if (exc instanceof SearchCancelled) return "Search cancelled.";
|
|
85
89
|
if (exc instanceof SearchTimeoutError) {
|
|
90
|
+
const providers = [...((exc as SearchTimeoutError).providers ?? [])].sort();
|
|
91
|
+
if (providers.length) return `Search failed: the search engines (${providers.join(", ")}) did not respond within ${Math.round(timeoutMs / 1000)} seconds.`;
|
|
86
92
|
return `Search failed: the search engines did not respond within ${Math.round(timeoutMs / 1000)} seconds.`;
|
|
87
93
|
}
|
|
88
94
|
if (exc instanceof EmptySweepError || (exc instanceof Error && exc.message.includes("No results found"))) {
|