pi-unsloth-webtools 0.3.0 → 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 +55 -2
- package/agent-dir.ts +19 -0
- package/cache.ts +154 -0
- package/engines.ts +82 -9
- package/html-to-md.ts +84 -52
- package/index.ts +117 -63
- package/package.json +4 -1
- package/pdf.ts +6 -1
- package/settings.ts +114 -0
- package/web-access.ts +53 -27
- package/web-fetch.ts +296 -115
- package/web-search.ts +7 -1
package/web-fetch.ts
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
import { lookup as dnsLookup } from "node:dns/promises";
|
|
2
|
+
import type { LookupAllOptions } from "node:dns";
|
|
2
3
|
import http from "node:http";
|
|
3
4
|
import https from "node:https";
|
|
5
|
+
|
|
4
6
|
import { createBrotliDecompress, createGunzip, createInflate, createInflateRaw } from "node:zlib";
|
|
5
7
|
import type { IncomingMessage } from "node:http";
|
|
6
8
|
import type { Transform } from "node:stream";
|
|
7
9
|
import {
|
|
8
10
|
checkUrlAccess,
|
|
11
|
+
githubRawContentUrl,
|
|
9
12
|
githubRepoRawReadmeUrl,
|
|
10
13
|
githubRepoReadmeApiUrl,
|
|
11
14
|
isPublicIp,
|
|
15
|
+
MAX_SIGNAL_TIMEOUT_MS,
|
|
12
16
|
normalizeUrlScheme,
|
|
13
17
|
type WebsitePolicy,
|
|
14
18
|
} from "./web-access.ts";
|
|
15
19
|
import { collapseWhitespace, decodeHtmlEntities, feedHtml, htmlToMarkdown } from "./html-to-md.ts";
|
|
16
20
|
import type { AttrDict } from "./html-to-md.ts";
|
|
17
21
|
import { INVALID_CHARREFS } from "./entities.ts";
|
|
22
|
+
import { getCached, isFresh, setCached, staleNotice } from "./cache.ts";
|
|
18
23
|
import { extractPdfText } from "./pdf.ts";
|
|
19
24
|
import { randomUserAgent } from "./user-agents.ts";
|
|
20
25
|
|
|
@@ -23,8 +28,23 @@ const MAX_DECOMPRESSED_BYTES = 64 * 1024 * 1024;
|
|
|
23
28
|
const META_VALUE_MAX_CHARS = 300;
|
|
24
29
|
const MAX_PDF_FETCH_BYTES = 10 * 1024 * 1024;
|
|
25
30
|
const MAX_REQUESTS = 5;
|
|
26
|
-
const MAX_SIGNAL_TIMEOUT_MS = 2 ** 31 - 1;
|
|
27
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
|
+
}
|
|
28
48
|
|
|
29
49
|
const UTF32_LE_BOM = Buffer.from([0xff, 0xfe, 0x00, 0x00]);
|
|
30
50
|
const UTF32_BE_BOM = Buffer.from([0x00, 0x00, 0xfe, 0xff]);
|
|
@@ -119,8 +139,8 @@ export class FetchTimeoutError extends Error {
|
|
|
119
139
|
}
|
|
120
140
|
const FETCH_CANCELLED_MESSAGE = "Failed to fetch URL: cancelled.";
|
|
121
141
|
const FETCH_TIMEOUT_MESSAGE = "Failed to fetch URL: timed out.";
|
|
122
|
-
const TRUNCATED_BODY_NOTICE = "\n\n... (page truncated at the download limit)";
|
|
123
142
|
const TRUNCATED_BODY_SUFFIX = "... (page truncated at the download limit)";
|
|
143
|
+
const TRUNCATED_BODY_NOTICE = "\n\n" + TRUNCATED_BODY_SUFFIX;
|
|
124
144
|
|
|
125
145
|
function fetchErrorMessage(err: unknown): string {
|
|
126
146
|
if (err instanceof FetchCancelledError) return FETCH_CANCELLED_MESSAGE;
|
|
@@ -131,13 +151,13 @@ function fetchErrorMessage(err: unknown): string {
|
|
|
131
151
|
return `Failed to fetch URL: ${message}`;
|
|
132
152
|
}
|
|
133
153
|
|
|
154
|
+
function emptyResult(error: string, contentType = ""): RawFetchResult {
|
|
155
|
+
return { error, body: "", contentType };
|
|
156
|
+
}
|
|
157
|
+
|
|
134
158
|
function statusErrorResult(status: number): RawFetchResult {
|
|
135
159
|
const reason = http.STATUS_CODES[status] ?? "";
|
|
136
|
-
return {
|
|
137
|
-
error: `Failed to fetch URL: HTTP ${status}${reason ? ` ${reason}` : ""}`,
|
|
138
|
-
body: "",
|
|
139
|
-
contentType: "",
|
|
140
|
-
};
|
|
160
|
+
return emptyResult(`Failed to fetch URL: HTTP ${status}${reason ? ` ${reason}` : ""}`);
|
|
141
161
|
}
|
|
142
162
|
|
|
143
163
|
export interface FetchPageOptions {
|
|
@@ -165,6 +185,7 @@ export interface ResolvedHost {
|
|
|
165
185
|
reason: string;
|
|
166
186
|
ip: string;
|
|
167
187
|
family: number;
|
|
188
|
+
alternates?: { ip: string; family: number }[];
|
|
168
189
|
}
|
|
169
190
|
|
|
170
191
|
export interface FetchSeams {
|
|
@@ -204,14 +225,25 @@ export interface RawFetchResult {
|
|
|
204
225
|
}
|
|
205
226
|
|
|
206
227
|
function htmlProbe(body: string, re: RegExp): boolean {
|
|
207
|
-
let
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (
|
|
212
|
-
|
|
228
|
+
let i = 0;
|
|
229
|
+
const n = body.length;
|
|
230
|
+
while (i < n) {
|
|
231
|
+
while (i < n && /[ \t\n\r\f\v]/.test(body[i])) i++;
|
|
232
|
+
if (body.startsWith("<!--", i)) {
|
|
233
|
+
const close = body.indexOf("-->", i + 4);
|
|
234
|
+
if (close === -1) break;
|
|
235
|
+
i = close + 3;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (body.startsWith("<?", i)) {
|
|
239
|
+
const close = body.indexOf("?>", i + 2);
|
|
240
|
+
if (close === -1) break;
|
|
241
|
+
i = close + 2;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
break;
|
|
213
245
|
}
|
|
214
|
-
return re.test(
|
|
246
|
+
return re.test(body.slice(i, i + 256).toLowerCase());
|
|
215
247
|
}
|
|
216
248
|
|
|
217
249
|
export function looksLikeHtml(body: string): boolean {
|
|
@@ -361,6 +393,41 @@ function sniffMetaCharsetForHtml(bytes: Buffer, contentType: string): string | n
|
|
|
361
393
|
}
|
|
362
394
|
return sniffMetaCharset(bytes);
|
|
363
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
|
+
}
|
|
364
431
|
|
|
365
432
|
function decodeUtf32(bytes: Buffer, littleEndian: boolean): string {
|
|
366
433
|
let out = "";
|
|
@@ -455,32 +522,15 @@ function decodeTis620(bytes: Buffer): string {
|
|
|
455
522
|
}
|
|
456
523
|
|
|
457
524
|
|
|
458
|
-
function withAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
459
|
-
if (!signal) return promise;
|
|
460
|
-
return new Promise<T>((resolve, reject) => {
|
|
461
|
-
const onAbort = () => reject(new DOMException("aborted", "AbortError"));
|
|
462
|
-
if (signal.aborted) {
|
|
463
|
-
onAbort();
|
|
464
|
-
return;
|
|
465
|
-
}
|
|
466
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
467
|
-
promise.then(
|
|
468
|
-
(value) => {
|
|
469
|
-
signal.removeEventListener("abort", onAbort);
|
|
470
|
-
resolve(value);
|
|
471
|
-
},
|
|
472
|
-
(err) => {
|
|
473
|
-
signal.removeEventListener("abort", onAbort);
|
|
474
|
-
reject(err);
|
|
475
|
-
},
|
|
476
|
-
);
|
|
477
|
-
});
|
|
478
|
-
}
|
|
479
|
-
|
|
480
525
|
async function resolveAndValidate(hostname: string, signal?: AbortSignal): Promise<ResolvedHost> {
|
|
481
526
|
let addresses: { address: string; family: number }[];
|
|
482
527
|
try {
|
|
483
|
-
|
|
528
|
+
const lookupOptions: LookupAllOptions & { signal?: AbortSignal } = {
|
|
529
|
+
all: true,
|
|
530
|
+
verbatim: true,
|
|
531
|
+
signal,
|
|
532
|
+
};
|
|
533
|
+
addresses = await dnsLookup(hostname, lookupOptions);
|
|
484
534
|
} catch (err) {
|
|
485
535
|
return { ok: false, reason: `Failed to resolve host: ${err}`, ip: "", family: 0 };
|
|
486
536
|
}
|
|
@@ -492,19 +542,24 @@ async function resolveAndValidate(hostname: string, signal?: AbortSignal): Promi
|
|
|
492
542
|
return { ok: false, reason: `Blocked: refusing to fetch the non-public address ${entry.address}.`, ip: "", family: 0 };
|
|
493
543
|
}
|
|
494
544
|
}
|
|
545
|
+
addresses.sort((a, b) => (a.family === 4 ? 0 : 1) - (b.family === 4 ? 0 : 1));
|
|
495
546
|
const first = addresses[0];
|
|
496
|
-
return {
|
|
547
|
+
return {
|
|
548
|
+
ok: true,
|
|
549
|
+
reason: "",
|
|
550
|
+
ip: first.address,
|
|
551
|
+
family: first.family,
|
|
552
|
+
alternates: addresses.slice(1).map((entry) => ({ ip: entry.address, family: entry.family })),
|
|
553
|
+
};
|
|
497
554
|
}
|
|
498
|
-
|
|
499
|
-
|
|
500
555
|
function budgetExceededResult(
|
|
501
556
|
deadline: number | null,
|
|
502
557
|
signal: AbortSignal | undefined,
|
|
503
558
|
now: () => number = Date.now,
|
|
504
559
|
contentType = "",
|
|
505
560
|
): RawFetchResult | null {
|
|
506
|
-
if (signal?.aborted) return
|
|
507
|
-
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);
|
|
508
563
|
return null;
|
|
509
564
|
}
|
|
510
565
|
|
|
@@ -539,6 +594,8 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
|
|
|
539
594
|
callback(null, [{ address: opts.pinnedIp, family: opts.family }]),
|
|
540
595
|
};
|
|
541
596
|
let settled = false;
|
|
597
|
+
let resRef: IncomingMessage | null = null;
|
|
598
|
+
let decoderRef: Transform | null = null;
|
|
542
599
|
const settle = (action: () => void) => {
|
|
543
600
|
if (settled) return;
|
|
544
601
|
settled = true;
|
|
@@ -546,6 +603,7 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
|
|
|
546
603
|
action();
|
|
547
604
|
};
|
|
548
605
|
const request = transport.request(options, (res: IncomingMessage) => {
|
|
606
|
+
resRef = res;
|
|
549
607
|
const codec = contentEncodingCodec(res.headers["content-encoding"]);
|
|
550
608
|
const chunks: Buffer[] = [];
|
|
551
609
|
let total = 0;
|
|
@@ -603,7 +661,8 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
|
|
|
603
661
|
if (total >= limit) {
|
|
604
662
|
truncated =
|
|
605
663
|
codec !== null ||
|
|
606
|
-
|
|
664
|
+
take.length < chunk.length ||
|
|
665
|
+
(Number.isFinite(declaredLength) && declaredLength > total);
|
|
607
666
|
decoder?.destroy();
|
|
608
667
|
res.destroy();
|
|
609
668
|
finish(null, Buffer.concat(chunks));
|
|
@@ -621,6 +680,8 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
|
|
|
621
680
|
let rawFallbackTried = false;
|
|
622
681
|
let resEnded = false;
|
|
623
682
|
const wire = (d: Transform) => {
|
|
683
|
+
decoderRef = d;
|
|
684
|
+
decoder = d;
|
|
624
685
|
d.on("data", (chunk: Buffer) => {
|
|
625
686
|
outputStarted = true;
|
|
626
687
|
acceptData(chunk);
|
|
@@ -638,6 +699,7 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
|
|
|
638
699
|
rawFallbackTried = true;
|
|
639
700
|
d.destroy();
|
|
640
701
|
decoder = createInflateRaw({ maxOutputLength: MAX_DECOMPRESSED_BYTES });
|
|
702
|
+
decoderRef = decoder;
|
|
641
703
|
wire(decoder);
|
|
642
704
|
for (const buffered of rawBuffer) decoder.write(buffered);
|
|
643
705
|
if (resEnded) decoder.end();
|
|
@@ -652,6 +714,7 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
|
|
|
652
714
|
});
|
|
653
715
|
};
|
|
654
716
|
decoder = createDecodeStream(codec);
|
|
717
|
+
decoderRef = decoder;
|
|
655
718
|
wire(decoder);
|
|
656
719
|
res.on("data", (chunk: Buffer) => {
|
|
657
720
|
if (settled) return;
|
|
@@ -676,7 +739,11 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
|
|
|
676
739
|
finish(err.message, Buffer.concat(chunks));
|
|
677
740
|
});
|
|
678
741
|
});
|
|
679
|
-
const onAbort = () =>
|
|
742
|
+
const onAbort = () => {
|
|
743
|
+
decoderRef?.destroy();
|
|
744
|
+
resRef?.destroy();
|
|
745
|
+
request.destroy(new FetchCancelledError());
|
|
746
|
+
};
|
|
680
747
|
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
681
748
|
request.on("timeout", () => request.destroy(new FetchTimeoutError()));
|
|
682
749
|
request.on("error", (err) => settle(() => reject(err)));
|
|
@@ -701,31 +768,36 @@ export async function fetchUrlRaw(
|
|
|
701
768
|
const resolveHost = seams.resolve ?? resolveAndValidate;
|
|
702
769
|
const performRequest = seams.request ?? requestHop;
|
|
703
770
|
const resolveWithBudget = async (hostname: string): Promise<ResolvedHost> => {
|
|
704
|
-
const
|
|
705
|
-
const
|
|
706
|
-
const
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
+
}
|
|
711
783
|
};
|
|
712
|
-
|
|
784
|
+
const checkBudget = (contentType = ""): RawFetchResult | null => budgetExceededResult(deadline, signal, now, contentType);
|
|
713
785
|
url = normalizeUrlScheme(url);
|
|
714
786
|
const [allowed, reason, hostname] = checkUrlAccess(url, policy);
|
|
715
|
-
if (!allowed) return
|
|
716
|
-
|
|
717
|
-
const budgetResult = budgetExceededResult(deadline, signal, now);
|
|
787
|
+
if (!allowed) return emptyResult(reason);
|
|
788
|
+
const budgetResult = checkBudget();
|
|
718
789
|
if (budgetResult !== null) return budgetResult;
|
|
719
790
|
let resolved = await resolveWithBudget(hostname);
|
|
720
|
-
if (!resolved.ok) return
|
|
791
|
+
if (!resolved.ok) return emptyResult(resolved.reason);
|
|
721
792
|
|
|
722
793
|
let currentUrl = url;
|
|
723
794
|
let pinnedIp = resolved.ip;
|
|
724
795
|
let pinnedFamily = resolved.family;
|
|
796
|
+
let alternates: { ip: string; family: number }[] = resolved.alternates ?? [];
|
|
797
|
+
let alternateIndex = 0;
|
|
725
798
|
const userAgent = randomUserAgent();
|
|
726
|
-
|
|
727
799
|
for (let hop = 0; hop < MAX_REQUESTS; hop++) {
|
|
728
|
-
const budgetResult =
|
|
800
|
+
const budgetResult = checkBudget();
|
|
729
801
|
if (budgetResult !== null) return budgetResult;
|
|
730
802
|
const parsed = new URL(currentUrl);
|
|
731
803
|
const hostHeader = parsed.hostname + (parsed.port ? `:${parsed.port}` : "");
|
|
@@ -736,7 +808,7 @@ export async function fetchUrlRaw(
|
|
|
736
808
|
"Accept-Encoding": "identity",
|
|
737
809
|
};
|
|
738
810
|
if (options.extraHeaders) Object.assign(headers, options.extraHeaders);
|
|
739
|
-
const inactivity =
|
|
811
|
+
const inactivity = remainingMs(deadline, now);
|
|
740
812
|
let response: HopResponse;
|
|
741
813
|
try {
|
|
742
814
|
response = await performRequest({
|
|
@@ -752,7 +824,17 @@ export async function fetchUrlRaw(
|
|
|
752
824
|
signal,
|
|
753
825
|
});
|
|
754
826
|
} catch (err) {
|
|
755
|
-
|
|
827
|
+
if (
|
|
828
|
+
!(err instanceof FetchCancelledError) &&
|
|
829
|
+
!(err instanceof FetchTimeoutError) &&
|
|
830
|
+
alternateIndex < alternates.length
|
|
831
|
+
) {
|
|
832
|
+
const next = alternates[alternateIndex++];
|
|
833
|
+
pinnedIp = next.ip;
|
|
834
|
+
pinnedFamily = next.family;
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
return emptyResult(fetchErrorMessage(err));
|
|
756
838
|
}
|
|
757
839
|
|
|
758
840
|
if (response.status >= 300 && response.status < 400) {
|
|
@@ -762,30 +844,28 @@ export async function fetchUrlRaw(
|
|
|
762
844
|
const rawLocation = response.headers.location;
|
|
763
845
|
const location = Array.isArray(rawLocation) ? rawLocation[0] : rawLocation;
|
|
764
846
|
if (!location) {
|
|
765
|
-
return
|
|
766
|
-
error: "Failed to fetch URL: the redirect is missing a Location header.",
|
|
767
|
-
body: "",
|
|
768
|
-
contentType: "",
|
|
769
|
-
};
|
|
847
|
+
return emptyResult("Failed to fetch URL: the redirect is missing a Location header.");
|
|
770
848
|
}
|
|
771
849
|
try {
|
|
772
850
|
currentUrl = new URL(location, currentUrl).toString();
|
|
773
851
|
} catch {
|
|
774
|
-
return
|
|
852
|
+
return emptyResult("Failed to fetch URL: the redirect has an invalid Location.");
|
|
775
853
|
}
|
|
776
854
|
const [redirectAllowed, redirectReason, redirectHost] = checkUrlAccess(
|
|
777
855
|
currentUrl,
|
|
778
856
|
policy,
|
|
779
857
|
);
|
|
780
|
-
if (!redirectAllowed) return
|
|
858
|
+
if (!redirectAllowed) return emptyResult(redirectReason);
|
|
781
859
|
const redirected = await resolveWithBudget(redirectHost);
|
|
782
|
-
if (!redirected.ok) return
|
|
860
|
+
if (!redirected.ok) return emptyResult(redirected.reason);
|
|
783
861
|
pinnedIp = redirected.ip;
|
|
784
862
|
pinnedFamily = redirected.family;
|
|
863
|
+
alternates = redirected.alternates ?? [];
|
|
864
|
+
alternateIndex = 0;
|
|
785
865
|
continue;
|
|
786
866
|
}
|
|
787
867
|
|
|
788
|
-
const postBudgetResult =
|
|
868
|
+
const postBudgetResult = checkBudget();
|
|
789
869
|
if (postBudgetResult !== null) return postBudgetResult;
|
|
790
870
|
|
|
791
871
|
if (response.status >= 400) {
|
|
@@ -812,46 +892,59 @@ export async function fetchUrlRaw(
|
|
|
812
892
|
try {
|
|
813
893
|
pdfText = await extractPdfText(response.body);
|
|
814
894
|
} catch {
|
|
815
|
-
return
|
|
816
|
-
|
|
895
|
+
return emptyResult(
|
|
896
|
+
response.truncated
|
|
817
897
|
? "(PDF content could not be read as text; the download was truncated at the download limit)"
|
|
818
898
|
: "(PDF content could not be read as text)",
|
|
819
|
-
body: "",
|
|
820
899
|
contentType,
|
|
821
|
-
|
|
900
|
+
);
|
|
822
901
|
}
|
|
823
|
-
const budgetResult =
|
|
902
|
+
const budgetResult = checkBudget(contentType);
|
|
824
903
|
if (budgetResult !== null) return budgetResult;
|
|
825
904
|
if (!pdfText) pdfText = "(PDF contains no extractable text)";
|
|
826
|
-
|
|
905
|
+
pdfText = withTruncation(pdfText, Boolean(response.truncated));
|
|
827
906
|
return { error: null, body: pdfText, contentType: "application/pdf" };
|
|
828
907
|
}
|
|
829
908
|
|
|
830
909
|
if (!isTextCandidateContentType(contentType)) {
|
|
831
910
|
const safeType = parseContentType(contentType) || "unknown type";
|
|
832
|
-
return
|
|
833
|
-
|
|
834
|
-
body: "",
|
|
911
|
+
return emptyResult(
|
|
912
|
+
`(non-text content: ${safeType}, ${response.body.length} bytes; not readable as text)`,
|
|
835
913
|
contentType,
|
|
836
|
-
|
|
914
|
+
);
|
|
837
915
|
}
|
|
838
916
|
|
|
839
917
|
if (hasBinaryMagic(response.body)) {
|
|
840
|
-
return
|
|
841
|
-
|
|
842
|
-
body: "",
|
|
918
|
+
return emptyResult(
|
|
919
|
+
`(binary content, ${response.body.length} bytes; not readable as text)`,
|
|
843
920
|
contentType,
|
|
844
|
-
|
|
921
|
+
);
|
|
845
922
|
}
|
|
846
923
|
|
|
847
924
|
const declaredCodec = declaredCharset ? normalizeCharset(declaredCharset) : null;
|
|
848
925
|
const bomCodec = bomCodecFor(response.body);
|
|
849
|
-
const rawHtml =
|
|
926
|
+
const rawHtml = withTruncation(
|
|
850
927
|
decodeWithCodec(
|
|
851
928
|
response.body,
|
|
852
929
|
bomCodec ?? declaredCodec ?? sniffMetaCharsetForHtml(response.body, contentType) ?? "utf-8",
|
|
853
|
-
)
|
|
854
|
-
|
|
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
|
+
}
|
|
855
948
|
if (looksBinary(rawHtml)) {
|
|
856
949
|
let alt: string | null = null;
|
|
857
950
|
if (
|
|
@@ -862,19 +955,18 @@ export async function fetchUrlRaw(
|
|
|
862
955
|
if (!looksBinary(candidate)) alt = candidate;
|
|
863
956
|
}
|
|
864
957
|
if (alt !== null) {
|
|
865
|
-
|
|
958
|
+
alt = withTruncation(alt, Boolean(response.truncated));
|
|
866
959
|
return { error: null, body: alt, contentType };
|
|
867
960
|
}
|
|
868
|
-
return
|
|
869
|
-
|
|
870
|
-
body: "",
|
|
961
|
+
return emptyResult(
|
|
962
|
+
`(binary content, ${response.body.length} bytes; not readable as text)`,
|
|
871
963
|
contentType,
|
|
872
|
-
|
|
964
|
+
);
|
|
873
965
|
}
|
|
874
966
|
|
|
875
967
|
return { error: null, body: rawHtml, contentType };
|
|
876
968
|
}
|
|
877
|
-
return
|
|
969
|
+
return emptyResult("Failed to fetch URL: too many redirects.");
|
|
878
970
|
}
|
|
879
971
|
|
|
880
972
|
function bomCodecFor(bytes: Buffer): string | null {
|
|
@@ -966,8 +1058,14 @@ const META_KEYS: Record<string, keyof PageMeta> = {
|
|
|
966
1058
|
|
|
967
1059
|
function cutAtCharBoundary(text: string, maxChars: number): string {
|
|
968
1060
|
const sliced = text.slice(0, maxChars);
|
|
1061
|
+
if (!sliced) return sliced;
|
|
969
1062
|
const last = sliced.charCodeAt(sliced.length - 1);
|
|
970
|
-
|
|
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;
|
|
971
1069
|
}
|
|
972
1070
|
|
|
973
1071
|
function capMetaValue(value: string): string {
|
|
@@ -1011,10 +1109,7 @@ function pagePrefixedMarkdown(html: string): string {
|
|
|
1011
1109
|
if (meta.site) lines.push(`Site: ${meta.site}`);
|
|
1012
1110
|
const markdown = htmlToMarkdown(html, true);
|
|
1013
1111
|
const converted = lines.length ? `${lines.join("\n")}\n\n${markdown}` : markdown;
|
|
1014
|
-
|
|
1015
|
-
return converted + TRUNCATED_BODY_NOTICE;
|
|
1016
|
-
}
|
|
1017
|
-
return converted;
|
|
1112
|
+
return preserveTruncationNotice(html, converted);
|
|
1018
1113
|
}
|
|
1019
1114
|
|
|
1020
1115
|
function formatReadmeBody(body: string): string {
|
|
@@ -1024,7 +1119,62 @@ function formatReadmeBody(body: string): string {
|
|
|
1024
1119
|
}
|
|
1025
1120
|
return body;
|
|
1026
1121
|
}
|
|
1027
|
-
|
|
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
|
+
}
|
|
1028
1178
|
export async function fetchPageText(
|
|
1029
1179
|
url: string,
|
|
1030
1180
|
options: FetchPageOptions = {},
|
|
@@ -1036,7 +1186,6 @@ export async function fetchPageText(
|
|
|
1036
1186
|
const policy = options.websitePolicy ?? null;
|
|
1037
1187
|
const maxChars = options.maxChars;
|
|
1038
1188
|
const rawFetch = options.rawFetch ?? fetchUrlRaw;
|
|
1039
|
-
|
|
1040
1189
|
url = normalizeUrlScheme(url);
|
|
1041
1190
|
const [allowed, reason] = checkUrlAccess(url, policy);
|
|
1042
1191
|
if (!allowed) return reason;
|
|
@@ -1048,7 +1197,16 @@ export async function fetchPageText(
|
|
|
1048
1197
|
maxPdfBytes: options.maxPdfBytes,
|
|
1049
1198
|
seams: options.seams,
|
|
1050
1199
|
};
|
|
1051
|
-
|
|
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
|
+
}
|
|
1052
1210
|
const readmeApiUrl = githubRepoReadmeApiUrl(url);
|
|
1053
1211
|
if (readmeApiUrl) {
|
|
1054
1212
|
const readmeResult = await rawFetch(readmeApiUrl, {
|
|
@@ -1060,29 +1218,52 @@ export async function fetchPageText(
|
|
|
1060
1218
|
});
|
|
1061
1219
|
const apiBody = readmeResult.error === null ? formatReadmeBody(readmeResult.body) : "";
|
|
1062
1220
|
if (apiBody.trim()) {
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
);
|
|
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);
|
|
1067
1224
|
}
|
|
1068
1225
|
const rawReadmeUrl = githubRepoRawReadmeUrl(url);
|
|
1069
1226
|
if (rawReadmeUrl) {
|
|
1070
1227
|
const rawResult = await rawFetch(rawReadmeUrl, rawFetchOptions);
|
|
1071
1228
|
const rawBody = rawResult.error === null ? formatReadmeBody(rawResult.body) : "";
|
|
1072
1229
|
if (rawBody.trim()) {
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
);
|
|
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);
|
|
1077
1233
|
}
|
|
1078
1234
|
}
|
|
1079
1235
|
}
|
|
1080
|
-
|
|
1236
|
+
const originalUrl = url;
|
|
1081
1237
|
const result = await rawFetch(url, rawFetchOptions);
|
|
1082
|
-
if (result.error !== null)
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
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);
|
|
1088
1269
|
}
|