pi-unsloth-webtools 0.3.1 → 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 +52 -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 +311 -106
- 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,25 @@ 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
|
|
31
|
+
const MAX_DNS_ATTEMPTS = 2;
|
|
32
|
+
const DNS_RETRY_BACKOFF_MS = 250;
|
|
28
33
|
export const DEFAULT_FETCH_TIMEOUT_MS = 60_000;
|
|
34
|
+
export { loadDefaultFetchMaxChars, loadDefaultFetchTimeoutMs, loadDefaultFetchSettings } from "./settings.ts";
|
|
35
|
+
function remainingMs(deadline: number, now: () => number): number {
|
|
36
|
+
return Math.max(1, deadline - now());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function clampedRemainingMs(deadline: number, now: () => number): number {
|
|
40
|
+
return Math.min(MAX_SIGNAL_TIMEOUT_MS, remainingMs(deadline, now));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function withTruncation(text: string, truncated: boolean): string {
|
|
44
|
+
return truncated ? text + TRUNCATED_BODY_NOTICE : text;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function preserveTruncationNotice(original: string, converted: string): string {
|
|
48
|
+
return original.endsWith(TRUNCATED_BODY_SUFFIX) && !converted.endsWith(TRUNCATED_BODY_SUFFIX) ? converted + TRUNCATED_BODY_NOTICE : converted;
|
|
49
|
+
}
|
|
29
50
|
|
|
30
51
|
const UTF32_LE_BOM = Buffer.from([0xff, 0xfe, 0x00, 0x00]);
|
|
31
52
|
const UTF32_BE_BOM = Buffer.from([0x00, 0x00, 0xfe, 0xff]);
|
|
@@ -120,8 +141,8 @@ export class FetchTimeoutError extends Error {
|
|
|
120
141
|
}
|
|
121
142
|
const FETCH_CANCELLED_MESSAGE = "Failed to fetch URL: cancelled.";
|
|
122
143
|
const FETCH_TIMEOUT_MESSAGE = "Failed to fetch URL: timed out.";
|
|
123
|
-
const TRUNCATED_BODY_NOTICE = "\n\n... (page truncated at the download limit)";
|
|
124
144
|
const TRUNCATED_BODY_SUFFIX = "... (page truncated at the download limit)";
|
|
145
|
+
const TRUNCATED_BODY_NOTICE = "\n\n" + TRUNCATED_BODY_SUFFIX;
|
|
125
146
|
|
|
126
147
|
function fetchErrorMessage(err: unknown): string {
|
|
127
148
|
if (err instanceof FetchCancelledError) return FETCH_CANCELLED_MESSAGE;
|
|
@@ -132,13 +153,13 @@ function fetchErrorMessage(err: unknown): string {
|
|
|
132
153
|
return `Failed to fetch URL: ${message}`;
|
|
133
154
|
}
|
|
134
155
|
|
|
156
|
+
function emptyResult(error: string, contentType = ""): RawFetchResult {
|
|
157
|
+
return { error, body: "", contentType };
|
|
158
|
+
}
|
|
159
|
+
|
|
135
160
|
function statusErrorResult(status: number): RawFetchResult {
|
|
136
161
|
const reason = http.STATUS_CODES[status] ?? "";
|
|
137
|
-
return {
|
|
138
|
-
error: `Failed to fetch URL: HTTP ${status}${reason ? ` ${reason}` : ""}`,
|
|
139
|
-
body: "",
|
|
140
|
-
contentType: "",
|
|
141
|
-
};
|
|
162
|
+
return emptyResult(`Failed to fetch URL: HTTP ${status}${reason ? ` ${reason}` : ""}`);
|
|
142
163
|
}
|
|
143
164
|
|
|
144
165
|
export interface FetchPageOptions {
|
|
@@ -374,6 +395,41 @@ function sniffMetaCharsetForHtml(bytes: Buffer, contentType: string): string | n
|
|
|
374
395
|
}
|
|
375
396
|
return sniffMetaCharset(bytes);
|
|
376
397
|
}
|
|
398
|
+
function extractMetaRefresh(html: string): string | null {
|
|
399
|
+
let refresh: string | null = null;
|
|
400
|
+
const handle = (name: string, attrs: AttrDict) => {
|
|
401
|
+
if (refresh !== null) return;
|
|
402
|
+
if (name !== "meta") return;
|
|
403
|
+
const equiv = (attrs["http-equiv"] ?? "").toLowerCase();
|
|
404
|
+
if (equiv !== "refresh") return;
|
|
405
|
+
const content = attrs["content"] ?? "";
|
|
406
|
+
const match = /;\s*url\s*=\s*(.+)/i.exec(content);
|
|
407
|
+
if (!match) return;
|
|
408
|
+
let url = match[1].trim();
|
|
409
|
+
if ((url.startsWith('"') && url.endsWith('"')) || (url.startsWith("'") && url.endsWith("'"))) url = url.slice(1, -1);
|
|
410
|
+
url = url.trim();
|
|
411
|
+
if (url) refresh = url;
|
|
412
|
+
};
|
|
413
|
+
feedHtml(html, {
|
|
414
|
+
handleStartTag: handle,
|
|
415
|
+
handleStartEndTag: handle,
|
|
416
|
+
handleEndTag() {},
|
|
417
|
+
handleData() {},
|
|
418
|
+
handleEntityRef() {},
|
|
419
|
+
handleCharRef() {},
|
|
420
|
+
});
|
|
421
|
+
return refresh;
|
|
422
|
+
}
|
|
423
|
+
function metaRefreshUrl(html: string, base: string): string | null {
|
|
424
|
+
const target = extractMetaRefresh(html);
|
|
425
|
+
if (!target) return null;
|
|
426
|
+
try {
|
|
427
|
+
const next = new URL(target, base).toString();
|
|
428
|
+
return next !== base ? next : null;
|
|
429
|
+
} catch {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
377
433
|
|
|
378
434
|
function decodeUtf32(bytes: Buffer, littleEndian: boolean): string {
|
|
379
435
|
let out = "";
|
|
@@ -468,46 +524,83 @@ function decodeTis620(bytes: Buffer): string {
|
|
|
468
524
|
}
|
|
469
525
|
|
|
470
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
|
+
|
|
471
552
|
async function resolveAndValidate(hostname: string, signal?: AbortSignal): Promise<ResolvedHost> {
|
|
472
|
-
let
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
signal
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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;
|
|
489
587
|
}
|
|
490
588
|
}
|
|
491
|
-
addresses.sort((a, b) => (a.family === 4 ? 0 : 1) - (b.family === 4 ? 0 : 1));
|
|
492
|
-
const first = addresses[0];
|
|
493
589
|
return {
|
|
494
|
-
ok:
|
|
495
|
-
reason: ""
|
|
496
|
-
ip:
|
|
497
|
-
family:
|
|
498
|
-
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,
|
|
499
594
|
};
|
|
500
595
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
596
|
function budgetExceededResult(
|
|
504
597
|
deadline: number | null,
|
|
505
598
|
signal: AbortSignal | undefined,
|
|
506
599
|
now: () => number = Date.now,
|
|
507
600
|
contentType = "",
|
|
508
601
|
): RawFetchResult | null {
|
|
509
|
-
if (signal?.aborted) return
|
|
510
|
-
if (deadline !== null && now() >= deadline) return
|
|
602
|
+
if (signal?.aborted) return emptyResult(FETCH_CANCELLED_MESSAGE, contentType);
|
|
603
|
+
if (deadline !== null && now() >= deadline) return emptyResult(FETCH_TIMEOUT_MESSAGE, contentType);
|
|
511
604
|
return null;
|
|
512
605
|
}
|
|
513
606
|
|
|
@@ -542,6 +635,8 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
|
|
|
542
635
|
callback(null, [{ address: opts.pinnedIp, family: opts.family }]),
|
|
543
636
|
};
|
|
544
637
|
let settled = false;
|
|
638
|
+
let resRef: IncomingMessage | null = null;
|
|
639
|
+
let decoderRef: Transform | null = null;
|
|
545
640
|
const settle = (action: () => void) => {
|
|
546
641
|
if (settled) return;
|
|
547
642
|
settled = true;
|
|
@@ -549,6 +644,7 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
|
|
|
549
644
|
action();
|
|
550
645
|
};
|
|
551
646
|
const request = transport.request(options, (res: IncomingMessage) => {
|
|
647
|
+
resRef = res;
|
|
552
648
|
const codec = contentEncodingCodec(res.headers["content-encoding"]);
|
|
553
649
|
const chunks: Buffer[] = [];
|
|
554
650
|
let total = 0;
|
|
@@ -625,6 +721,8 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
|
|
|
625
721
|
let rawFallbackTried = false;
|
|
626
722
|
let resEnded = false;
|
|
627
723
|
const wire = (d: Transform) => {
|
|
724
|
+
decoderRef = d;
|
|
725
|
+
decoder = d;
|
|
628
726
|
d.on("data", (chunk: Buffer) => {
|
|
629
727
|
outputStarted = true;
|
|
630
728
|
acceptData(chunk);
|
|
@@ -642,6 +740,7 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
|
|
|
642
740
|
rawFallbackTried = true;
|
|
643
741
|
d.destroy();
|
|
644
742
|
decoder = createInflateRaw({ maxOutputLength: MAX_DECOMPRESSED_BYTES });
|
|
743
|
+
decoderRef = decoder;
|
|
645
744
|
wire(decoder);
|
|
646
745
|
for (const buffered of rawBuffer) decoder.write(buffered);
|
|
647
746
|
if (resEnded) decoder.end();
|
|
@@ -656,6 +755,7 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
|
|
|
656
755
|
});
|
|
657
756
|
};
|
|
658
757
|
decoder = createDecodeStream(codec);
|
|
758
|
+
decoderRef = decoder;
|
|
659
759
|
wire(decoder);
|
|
660
760
|
res.on("data", (chunk: Buffer) => {
|
|
661
761
|
if (settled) return;
|
|
@@ -680,7 +780,11 @@ export function requestHop(opts: HopOptions): Promise<HopResponse> {
|
|
|
680
780
|
finish(err.message, Buffer.concat(chunks));
|
|
681
781
|
});
|
|
682
782
|
});
|
|
683
|
-
const onAbort = () =>
|
|
783
|
+
const onAbort = () => {
|
|
784
|
+
decoderRef?.destroy();
|
|
785
|
+
resRef?.destroy();
|
|
786
|
+
request.destroy(new FetchCancelledError());
|
|
787
|
+
};
|
|
684
788
|
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
685
789
|
request.on("timeout", () => request.destroy(new FetchTimeoutError()));
|
|
686
790
|
request.on("error", (err) => settle(() => reject(err)));
|
|
@@ -705,23 +809,27 @@ export async function fetchUrlRaw(
|
|
|
705
809
|
const resolveHost = seams.resolve ?? resolveAndValidate;
|
|
706
810
|
const performRequest = seams.request ?? requestHop;
|
|
707
811
|
const resolveWithBudget = async (hostname: string): Promise<ResolvedHost> => {
|
|
708
|
-
const
|
|
709
|
-
const
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
812
|
+
const abortController = new AbortController();
|
|
813
|
+
const timeoutId = setTimeout(() => abortController.abort(), clampedRemainingMs(deadline, now));
|
|
814
|
+
const resolveSignal = signal ? AbortSignal.any([signal, abortController.signal]) : abortController.signal;
|
|
815
|
+
try {
|
|
816
|
+
const resolved = await resolveHost(hostname, resolveSignal);
|
|
817
|
+
if (resolved.ok) return resolved;
|
|
818
|
+
if (signal?.aborted) return { ...resolved, reason: FETCH_CANCELLED_MESSAGE };
|
|
819
|
+
if (resolveSignal.aborted) return { ...resolved, reason: FETCH_TIMEOUT_MESSAGE };
|
|
820
|
+
return resolved;
|
|
821
|
+
} finally {
|
|
822
|
+
clearTimeout(timeoutId);
|
|
823
|
+
}
|
|
715
824
|
};
|
|
716
|
-
|
|
825
|
+
const checkBudget = (contentType = ""): RawFetchResult | null => budgetExceededResult(deadline, signal, now, contentType);
|
|
717
826
|
url = normalizeUrlScheme(url);
|
|
718
827
|
const [allowed, reason, hostname] = checkUrlAccess(url, policy);
|
|
719
|
-
if (!allowed) return
|
|
720
|
-
|
|
721
|
-
const budgetResult = budgetExceededResult(deadline, signal, now);
|
|
828
|
+
if (!allowed) return emptyResult(reason);
|
|
829
|
+
const budgetResult = checkBudget();
|
|
722
830
|
if (budgetResult !== null) return budgetResult;
|
|
723
831
|
let resolved = await resolveWithBudget(hostname);
|
|
724
|
-
if (!resolved.ok) return
|
|
832
|
+
if (!resolved.ok) return emptyResult(resolved.reason);
|
|
725
833
|
|
|
726
834
|
let currentUrl = url;
|
|
727
835
|
let pinnedIp = resolved.ip;
|
|
@@ -730,7 +838,7 @@ export async function fetchUrlRaw(
|
|
|
730
838
|
let alternateIndex = 0;
|
|
731
839
|
const userAgent = randomUserAgent();
|
|
732
840
|
for (let hop = 0; hop < MAX_REQUESTS; hop++) {
|
|
733
|
-
const budgetResult =
|
|
841
|
+
const budgetResult = checkBudget();
|
|
734
842
|
if (budgetResult !== null) return budgetResult;
|
|
735
843
|
const parsed = new URL(currentUrl);
|
|
736
844
|
const hostHeader = parsed.hostname + (parsed.port ? `:${parsed.port}` : "");
|
|
@@ -741,7 +849,7 @@ export async function fetchUrlRaw(
|
|
|
741
849
|
"Accept-Encoding": "identity",
|
|
742
850
|
};
|
|
743
851
|
if (options.extraHeaders) Object.assign(headers, options.extraHeaders);
|
|
744
|
-
const inactivity =
|
|
852
|
+
const inactivity = remainingMs(deadline, now);
|
|
745
853
|
let response: HopResponse;
|
|
746
854
|
try {
|
|
747
855
|
response = await performRequest({
|
|
@@ -767,7 +875,7 @@ export async function fetchUrlRaw(
|
|
|
767
875
|
pinnedFamily = next.family;
|
|
768
876
|
continue;
|
|
769
877
|
}
|
|
770
|
-
return
|
|
878
|
+
return emptyResult(fetchErrorMessage(err));
|
|
771
879
|
}
|
|
772
880
|
|
|
773
881
|
if (response.status >= 300 && response.status < 400) {
|
|
@@ -777,24 +885,20 @@ export async function fetchUrlRaw(
|
|
|
777
885
|
const rawLocation = response.headers.location;
|
|
778
886
|
const location = Array.isArray(rawLocation) ? rawLocation[0] : rawLocation;
|
|
779
887
|
if (!location) {
|
|
780
|
-
return
|
|
781
|
-
error: "Failed to fetch URL: the redirect is missing a Location header.",
|
|
782
|
-
body: "",
|
|
783
|
-
contentType: "",
|
|
784
|
-
};
|
|
888
|
+
return emptyResult("Failed to fetch URL: the redirect is missing a Location header.");
|
|
785
889
|
}
|
|
786
890
|
try {
|
|
787
891
|
currentUrl = new URL(location, currentUrl).toString();
|
|
788
892
|
} catch {
|
|
789
|
-
return
|
|
893
|
+
return emptyResult("Failed to fetch URL: the redirect has an invalid Location.");
|
|
790
894
|
}
|
|
791
895
|
const [redirectAllowed, redirectReason, redirectHost] = checkUrlAccess(
|
|
792
896
|
currentUrl,
|
|
793
897
|
policy,
|
|
794
898
|
);
|
|
795
|
-
if (!redirectAllowed) return
|
|
899
|
+
if (!redirectAllowed) return emptyResult(redirectReason);
|
|
796
900
|
const redirected = await resolveWithBudget(redirectHost);
|
|
797
|
-
if (!redirected.ok) return
|
|
901
|
+
if (!redirected.ok) return emptyResult(redirected.reason);
|
|
798
902
|
pinnedIp = redirected.ip;
|
|
799
903
|
pinnedFamily = redirected.family;
|
|
800
904
|
alternates = redirected.alternates ?? [];
|
|
@@ -802,7 +906,7 @@ export async function fetchUrlRaw(
|
|
|
802
906
|
continue;
|
|
803
907
|
}
|
|
804
908
|
|
|
805
|
-
const postBudgetResult =
|
|
909
|
+
const postBudgetResult = checkBudget();
|
|
806
910
|
if (postBudgetResult !== null) return postBudgetResult;
|
|
807
911
|
|
|
808
912
|
if (response.status >= 400) {
|
|
@@ -829,46 +933,59 @@ export async function fetchUrlRaw(
|
|
|
829
933
|
try {
|
|
830
934
|
pdfText = await extractPdfText(response.body);
|
|
831
935
|
} catch {
|
|
832
|
-
return
|
|
833
|
-
|
|
936
|
+
return emptyResult(
|
|
937
|
+
response.truncated
|
|
834
938
|
? "(PDF content could not be read as text; the download was truncated at the download limit)"
|
|
835
939
|
: "(PDF content could not be read as text)",
|
|
836
|
-
body: "",
|
|
837
940
|
contentType,
|
|
838
|
-
|
|
941
|
+
);
|
|
839
942
|
}
|
|
840
|
-
const budgetResult =
|
|
943
|
+
const budgetResult = checkBudget(contentType);
|
|
841
944
|
if (budgetResult !== null) return budgetResult;
|
|
842
945
|
if (!pdfText) pdfText = "(PDF contains no extractable text)";
|
|
843
|
-
|
|
946
|
+
pdfText = withTruncation(pdfText, Boolean(response.truncated));
|
|
844
947
|
return { error: null, body: pdfText, contentType: "application/pdf" };
|
|
845
948
|
}
|
|
846
949
|
|
|
847
950
|
if (!isTextCandidateContentType(contentType)) {
|
|
848
951
|
const safeType = parseContentType(contentType) || "unknown type";
|
|
849
|
-
return
|
|
850
|
-
|
|
851
|
-
body: "",
|
|
952
|
+
return emptyResult(
|
|
953
|
+
`(non-text content: ${safeType}, ${response.body.length} bytes; not readable as text)`,
|
|
852
954
|
contentType,
|
|
853
|
-
|
|
955
|
+
);
|
|
854
956
|
}
|
|
855
957
|
|
|
856
958
|
if (hasBinaryMagic(response.body)) {
|
|
857
|
-
return
|
|
858
|
-
|
|
859
|
-
body: "",
|
|
959
|
+
return emptyResult(
|
|
960
|
+
`(binary content, ${response.body.length} bytes; not readable as text)`,
|
|
860
961
|
contentType,
|
|
861
|
-
|
|
962
|
+
);
|
|
862
963
|
}
|
|
863
964
|
|
|
864
965
|
const declaredCodec = declaredCharset ? normalizeCharset(declaredCharset) : null;
|
|
865
966
|
const bomCodec = bomCodecFor(response.body);
|
|
866
|
-
const rawHtml =
|
|
967
|
+
const rawHtml = withTruncation(
|
|
867
968
|
decodeWithCodec(
|
|
868
969
|
response.body,
|
|
869
970
|
bomCodec ?? declaredCodec ?? sniffMetaCharsetForHtml(response.body, contentType) ?? "utf-8",
|
|
870
|
-
)
|
|
871
|
-
|
|
971
|
+
),
|
|
972
|
+
Boolean(response.truncated),
|
|
973
|
+
);
|
|
974
|
+
const nextUrl = isHtmlContent(rawHtml, contentType) ? metaRefreshUrl(rawHtml, currentUrl) : null;
|
|
975
|
+
if (nextUrl) {
|
|
976
|
+
const [refreshAllowed, refreshReason, refreshHost] = checkUrlAccess(nextUrl, policy);
|
|
977
|
+
if (!refreshAllowed) return emptyResult(refreshReason);
|
|
978
|
+
const refreshBudget = checkBudget();
|
|
979
|
+
if (refreshBudget !== null) return refreshBudget;
|
|
980
|
+
const redirected = await resolveWithBudget(refreshHost);
|
|
981
|
+
if (!redirected.ok) return emptyResult(redirected.reason);
|
|
982
|
+
currentUrl = nextUrl;
|
|
983
|
+
pinnedIp = redirected.ip;
|
|
984
|
+
pinnedFamily = redirected.family;
|
|
985
|
+
alternates = redirected.alternates ?? [];
|
|
986
|
+
alternateIndex = 0;
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
872
989
|
if (looksBinary(rawHtml)) {
|
|
873
990
|
let alt: string | null = null;
|
|
874
991
|
if (
|
|
@@ -879,19 +996,18 @@ export async function fetchUrlRaw(
|
|
|
879
996
|
if (!looksBinary(candidate)) alt = candidate;
|
|
880
997
|
}
|
|
881
998
|
if (alt !== null) {
|
|
882
|
-
|
|
999
|
+
alt = withTruncation(alt, Boolean(response.truncated));
|
|
883
1000
|
return { error: null, body: alt, contentType };
|
|
884
1001
|
}
|
|
885
|
-
return
|
|
886
|
-
|
|
887
|
-
body: "",
|
|
1002
|
+
return emptyResult(
|
|
1003
|
+
`(binary content, ${response.body.length} bytes; not readable as text)`,
|
|
888
1004
|
contentType,
|
|
889
|
-
|
|
1005
|
+
);
|
|
890
1006
|
}
|
|
891
1007
|
|
|
892
1008
|
return { error: null, body: rawHtml, contentType };
|
|
893
1009
|
}
|
|
894
|
-
return
|
|
1010
|
+
return emptyResult("Failed to fetch URL: too many redirects.");
|
|
895
1011
|
}
|
|
896
1012
|
|
|
897
1013
|
function bomCodecFor(bytes: Buffer): string | null {
|
|
@@ -983,8 +1099,14 @@ const META_KEYS: Record<string, keyof PageMeta> = {
|
|
|
983
1099
|
|
|
984
1100
|
function cutAtCharBoundary(text: string, maxChars: number): string {
|
|
985
1101
|
const sliced = text.slice(0, maxChars);
|
|
1102
|
+
if (!sliced) return sliced;
|
|
986
1103
|
const last = sliced.charCodeAt(sliced.length - 1);
|
|
987
|
-
|
|
1104
|
+
if (last >= 0xd800 && last <= 0xdbff) return sliced.slice(0, -1);
|
|
1105
|
+
if (last >= 0xdc00 && last <= 0xdfff) {
|
|
1106
|
+
const prev = sliced.length >= 2 ? sliced.charCodeAt(sliced.length - 2) : 0;
|
|
1107
|
+
if (prev < 0xd800 || prev > 0xdbff) return sliced.slice(0, -1);
|
|
1108
|
+
}
|
|
1109
|
+
return sliced;
|
|
988
1110
|
}
|
|
989
1111
|
|
|
990
1112
|
function capMetaValue(value: string): string {
|
|
@@ -1028,10 +1150,7 @@ function pagePrefixedMarkdown(html: string): string {
|
|
|
1028
1150
|
if (meta.site) lines.push(`Site: ${meta.site}`);
|
|
1029
1151
|
const markdown = htmlToMarkdown(html, true);
|
|
1030
1152
|
const converted = lines.length ? `${lines.join("\n")}\n\n${markdown}` : markdown;
|
|
1031
|
-
|
|
1032
|
-
return converted + TRUNCATED_BODY_NOTICE;
|
|
1033
|
-
}
|
|
1034
|
-
return converted;
|
|
1153
|
+
return preserveTruncationNotice(html, converted);
|
|
1035
1154
|
}
|
|
1036
1155
|
|
|
1037
1156
|
function formatReadmeBody(body: string): string {
|
|
@@ -1041,7 +1160,62 @@ function formatReadmeBody(body: string): string {
|
|
|
1041
1160
|
}
|
|
1042
1161
|
return body;
|
|
1043
1162
|
}
|
|
1044
|
-
|
|
1163
|
+
function isHtmlContent(body: string, contentType: string): boolean {
|
|
1164
|
+
return contentType.includes("html") || looksLikeHtml(body);
|
|
1165
|
+
}
|
|
1166
|
+
function renderBody(body: string, contentType: string): string {
|
|
1167
|
+
return isHtmlContent(body, contentType) ? pagePrefixedMarkdown(body) : body.trim();
|
|
1168
|
+
}
|
|
1169
|
+
async function persistCache(url: string, body: string, contentType: string, useCache: boolean): Promise<void> {
|
|
1170
|
+
if (!useCache) return;
|
|
1171
|
+
try {
|
|
1172
|
+
await setCached(url, body, contentType);
|
|
1173
|
+
} catch {}
|
|
1174
|
+
}
|
|
1175
|
+
function isHttp404Error(error: string): boolean {
|
|
1176
|
+
return /HTTP 404\b/.test(error);
|
|
1177
|
+
}
|
|
1178
|
+
async function fetchWaybackSnapshot(
|
|
1179
|
+
url: string,
|
|
1180
|
+
rawFetch: (url: string, options: RawFetchOptions) => Promise<RawFetchResult>,
|
|
1181
|
+
options: RawFetchOptions,
|
|
1182
|
+
deadlineMs: number,
|
|
1183
|
+
now: () => number,
|
|
1184
|
+
): Promise<{ body: string; contentType: string; timestamp: string } | null> {
|
|
1185
|
+
if (now() >= deadlineMs) return null;
|
|
1186
|
+
const availUrl = `https://archive.org/wayback/available?url=${encodeURIComponent(url)}`;
|
|
1187
|
+
let availResult: RawFetchResult | null = null;
|
|
1188
|
+
try {
|
|
1189
|
+
availResult = await rawFetch(availUrl, options);
|
|
1190
|
+
} catch {
|
|
1191
|
+
return null;
|
|
1192
|
+
}
|
|
1193
|
+
if (!availResult || availResult.error !== null) return null;
|
|
1194
|
+
let snapshotUrl: string | null = null;
|
|
1195
|
+
let timestamp = "";
|
|
1196
|
+
try {
|
|
1197
|
+
const data = JSON.parse(availResult.body) as {
|
|
1198
|
+
archived_snapshots?: { closest?: { available?: boolean; url?: string; timestamp?: string; status?: string | number } };
|
|
1199
|
+
};
|
|
1200
|
+
const closest = data.archived_snapshots?.closest;
|
|
1201
|
+
if (closest?.available && closest.url && String(closest.status) === "200") {
|
|
1202
|
+
snapshotUrl = closest.url;
|
|
1203
|
+
timestamp = closest.timestamp ?? "";
|
|
1204
|
+
}
|
|
1205
|
+
} catch {
|
|
1206
|
+
return null;
|
|
1207
|
+
}
|
|
1208
|
+
if (!snapshotUrl) return null;
|
|
1209
|
+
if (now() >= deadlineMs) return null;
|
|
1210
|
+
let snapResult: RawFetchResult | null = null;
|
|
1211
|
+
try {
|
|
1212
|
+
snapResult = await rawFetch(snapshotUrl, options);
|
|
1213
|
+
} catch {
|
|
1214
|
+
return null;
|
|
1215
|
+
}
|
|
1216
|
+
if (!snapResult || snapResult.error !== null) return null;
|
|
1217
|
+
return { body: snapResult.body, contentType: snapResult.contentType, timestamp };
|
|
1218
|
+
}
|
|
1045
1219
|
export async function fetchPageText(
|
|
1046
1220
|
url: string,
|
|
1047
1221
|
options: FetchPageOptions = {},
|
|
@@ -1053,7 +1227,6 @@ export async function fetchPageText(
|
|
|
1053
1227
|
const policy = options.websitePolicy ?? null;
|
|
1054
1228
|
const maxChars = options.maxChars;
|
|
1055
1229
|
const rawFetch = options.rawFetch ?? fetchUrlRaw;
|
|
1056
|
-
|
|
1057
1230
|
url = normalizeUrlScheme(url);
|
|
1058
1231
|
const [allowed, reason] = checkUrlAccess(url, policy);
|
|
1059
1232
|
if (!allowed) return reason;
|
|
@@ -1065,7 +1238,16 @@ export async function fetchPageText(
|
|
|
1065
1238
|
maxPdfBytes: options.maxPdfBytes,
|
|
1066
1239
|
seams: options.seams,
|
|
1067
1240
|
};
|
|
1068
|
-
|
|
1241
|
+
const useCache = !options.seams && !options.rawFetch;
|
|
1242
|
+
const githubRaw = githubRawContentUrl(url);
|
|
1243
|
+
if (githubRaw) {
|
|
1244
|
+
const rawResult = await rawFetch(githubRaw, rawFetchOptions);
|
|
1245
|
+
if (rawResult.error === null) {
|
|
1246
|
+
const out = renderBody(rawResult.body, rawResult.contentType);
|
|
1247
|
+
await persistCache(url, rawResult.body, rawResult.contentType, useCache);
|
|
1248
|
+
return truncatePageText(out, maxChars);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1069
1251
|
const readmeApiUrl = githubRepoReadmeApiUrl(url);
|
|
1070
1252
|
if (readmeApiUrl) {
|
|
1071
1253
|
const readmeResult = await rawFetch(readmeApiUrl, {
|
|
@@ -1077,29 +1259,52 @@ export async function fetchPageText(
|
|
|
1077
1259
|
});
|
|
1078
1260
|
const apiBody = readmeResult.error === null ? formatReadmeBody(readmeResult.body) : "";
|
|
1079
1261
|
if (apiBody.trim()) {
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
);
|
|
1262
|
+
const rendered = `README of ${url} (fetched via the GitHub README API):\n\n` + apiBody;
|
|
1263
|
+
await persistCache(url, rendered, "text/markdown", useCache);
|
|
1264
|
+
return truncatePageText(rendered, maxChars);
|
|
1084
1265
|
}
|
|
1085
1266
|
const rawReadmeUrl = githubRepoRawReadmeUrl(url);
|
|
1086
1267
|
if (rawReadmeUrl) {
|
|
1087
1268
|
const rawResult = await rawFetch(rawReadmeUrl, rawFetchOptions);
|
|
1088
1269
|
const rawBody = rawResult.error === null ? formatReadmeBody(rawResult.body) : "";
|
|
1089
1270
|
if (rawBody.trim()) {
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
);
|
|
1271
|
+
const rendered = `README of ${url} (fetched via the GitHub raw README URL):\n\n` + rawBody;
|
|
1272
|
+
await persistCache(url, rendered, "text/markdown", useCache);
|
|
1273
|
+
return truncatePageText(rendered, maxChars);
|
|
1094
1274
|
}
|
|
1095
1275
|
}
|
|
1096
1276
|
}
|
|
1097
|
-
|
|
1277
|
+
const originalUrl = url;
|
|
1098
1278
|
const result = await rawFetch(url, rawFetchOptions);
|
|
1099
|
-
if (result.error !== null)
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1279
|
+
if (result.error !== null) {
|
|
1280
|
+
if (!result.error.startsWith("Blocked:")) {
|
|
1281
|
+
if (useCache) {
|
|
1282
|
+
try {
|
|
1283
|
+
const cached = await getCached(originalUrl);
|
|
1284
|
+
if (cached) {
|
|
1285
|
+
let cachedOut = renderBody(cached.body, cached.contentType);
|
|
1286
|
+
if (!isFresh(cached, now())) cachedOut += staleNotice(cached);
|
|
1287
|
+
else cachedOut += "\n\n*Served from cache — network fetch failed*";
|
|
1288
|
+
return truncatePageText(cachedOut, maxChars);
|
|
1289
|
+
}
|
|
1290
|
+
} catch {}
|
|
1291
|
+
}
|
|
1292
|
+
if (isHttp404Error(result.error)) {
|
|
1293
|
+
try {
|
|
1294
|
+
const wb = await fetchWaybackSnapshot(originalUrl, rawFetch, rawFetchOptions, deadlineMs, now);
|
|
1295
|
+
if (wb) {
|
|
1296
|
+
let out = renderBody(wb.body, wb.contentType);
|
|
1297
|
+
const ts = wb.timestamp ? `${wb.timestamp.slice(0, 4)}-${wb.timestamp.slice(4, 6)}-${wb.timestamp.slice(6, 8)}` : "unknown date";
|
|
1298
|
+
out = `*Fetched from Wayback Machine snapshot (${ts}) for ${originalUrl}:*\n\n` + out;
|
|
1299
|
+
await persistCache(originalUrl, wb.body, wb.contentType, useCache);
|
|
1300
|
+
return truncatePageText(out, maxChars);
|
|
1301
|
+
}
|
|
1302
|
+
} catch {}
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
return result.error;
|
|
1306
|
+
}
|
|
1307
|
+
const finalOut = renderBody(result.body, result.contentType);
|
|
1308
|
+
await persistCache(originalUrl, result.body, result.contentType, useCache);
|
|
1309
|
+
return truncatePageText(finalOut, maxChars);
|
|
1105
1310
|
}
|