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/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 probe = body;
208
- while (true) {
209
- probe = probe.replace(/^[ \t\n\r\f\v]+/, "");
210
- const stripped = probe.replace(/^(?:<!--[\s\S]*?-->|<\?[\s\S]*?\?>)/, "");
211
- if (stripped === probe) break;
212
- probe = stripped;
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(probe.slice(0, 256).toLowerCase());
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
- addresses = await withAbort(dnsLookup(hostname, { all: true, verbatim: true }), signal);
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 { ok: true, reason: "", ip: first.address, family: first.family };
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 { error: FETCH_CANCELLED_MESSAGE, body: "", contentType };
507
- if (deadline !== null && now() >= deadline) return { error: FETCH_TIMEOUT_MESSAGE, body: "", contentType };
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
- !(Number.isFinite(declaredLength) && declaredLength === total);
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 = () => request.destroy(new FetchCancelledError());
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 deadlineSignal = AbortSignal.timeout(Math.min(MAX_SIGNAL_TIMEOUT_MS, Math.max(1, deadline - now())));
705
- const resolveSignal = signal ? AbortSignal.any([signal, deadlineSignal]) : deadlineSignal;
706
- const resolved = await resolveHost(hostname, resolveSignal);
707
- if (resolved.ok) return resolved;
708
- if (signal?.aborted) return { ...resolved, reason: FETCH_CANCELLED_MESSAGE };
709
- if (resolveSignal.aborted) return { ...resolved, reason: FETCH_TIMEOUT_MESSAGE };
710
- return resolved;
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 { error: reason, body: "", contentType: "" };
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 { error: resolved.reason, body: "", contentType: "" };
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 = budgetExceededResult(deadline, signal, now);
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 = Math.max(1, deadline - now());
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
- return { error: fetchErrorMessage(err), body: "", contentType: "" };
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 { error: "Failed to fetch URL: the redirect has an invalid Location.", body: "", contentType: "" };
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 { error: redirectReason, body: "", contentType: "" };
858
+ if (!redirectAllowed) return emptyResult(redirectReason);
781
859
  const redirected = await resolveWithBudget(redirectHost);
782
- if (!redirected.ok) return { error: redirected.reason, body: "", contentType: "" };
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 = budgetExceededResult(deadline, signal, now);
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
- error: response.truncated
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 = budgetExceededResult(deadline, signal, now, contentType);
902
+ const budgetResult = checkBudget(contentType);
824
903
  if (budgetResult !== null) return budgetResult;
825
904
  if (!pdfText) pdfText = "(PDF contains no extractable text)";
826
- if (response.truncated) pdfText += TRUNCATED_BODY_NOTICE;
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
- error: `(non-text content: ${safeType}, ${response.body.length} bytes; not readable as text)`,
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
- error: `(binary content, ${response.body.length} bytes; not readable as text)`,
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
- ) + (response.truncated ? TRUNCATED_BODY_NOTICE : "");
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
- if (response.truncated) alt += TRUNCATED_BODY_NOTICE;
958
+ alt = withTruncation(alt, Boolean(response.truncated));
866
959
  return { error: null, body: alt, contentType };
867
960
  }
868
- return {
869
- error: `(binary content, ${response.body.length} bytes; not readable as text)`,
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 { error: "Failed to fetch URL: too many redirects.", body: "", contentType: "" };
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
- return last >= 0xd800 && last <= 0xdbff ? sliced.slice(0, -1) : sliced;
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
- if (html.endsWith(TRUNCATED_BODY_SUFFIX) && !converted.endsWith(TRUNCATED_BODY_SUFFIX)) {
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
- return truncatePageText(
1064
- `README of ${url} (fetched via the GitHub README API):\n\n` + apiBody,
1065
- maxChars,
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
- return truncatePageText(
1074
- `README of ${url} (fetched via the GitHub raw README URL):\n\n` + rawBody,
1075
- maxChars,
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) return result.error;
1083
-
1084
- const isHtml = result.contentType.includes("html") || looksLikeHtml(result.body);
1085
- if (!isHtml) return truncatePageText(result.body.trim(), maxChars);
1086
-
1087
- return truncatePageText(pagePrefixedMarkdown(result.body), maxChars);
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
  }