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/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 MAX_SIGNAL_TIMEOUT_MS = 2 ** 31 - 1;
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 addresses: { address: string; family: number }[];
473
- try {
474
- const lookupOptions: LookupAllOptions & { signal?: AbortSignal } = {
475
- all: true,
476
- verbatim: true,
477
- signal,
478
- };
479
- addresses = await dnsLookup(hostname, lookupOptions);
480
- } catch (err) {
481
- return { ok: false, reason: `Failed to resolve host: ${err}`, ip: "", family: 0 };
482
- }
483
- if (!addresses.length) {
484
- return { ok: false, reason: `Failed to resolve host: no addresses for '${hostname}'`, ip: "", family: 0 };
485
- }
486
- for (const entry of addresses) {
487
- if (!isPublicIp(entry.address)) {
488
- return { ok: false, reason: `Blocked: refusing to fetch the non-public address ${entry.address}.`, ip: "", family: 0 };
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: true,
495
- reason: "",
496
- ip: first.address,
497
- family: first.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 { error: FETCH_CANCELLED_MESSAGE, body: "", contentType };
510
- if (deadline !== null && now() >= deadline) return { error: FETCH_TIMEOUT_MESSAGE, body: "", contentType };
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 = () => request.destroy(new FetchCancelledError());
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 deadlineSignal = AbortSignal.timeout(Math.min(MAX_SIGNAL_TIMEOUT_MS, Math.max(1, deadline - now())));
709
- const resolveSignal = signal ? AbortSignal.any([signal, deadlineSignal]) : deadlineSignal;
710
- const resolved = await resolveHost(hostname, resolveSignal);
711
- if (resolved.ok) return resolved;
712
- if (signal?.aborted) return { ...resolved, reason: FETCH_CANCELLED_MESSAGE };
713
- if (resolveSignal.aborted) return { ...resolved, reason: FETCH_TIMEOUT_MESSAGE };
714
- return resolved;
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 { error: reason, body: "", contentType: "" };
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 { error: resolved.reason, body: "", contentType: "" };
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 = budgetExceededResult(deadline, signal, now);
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 = Math.max(1, deadline - now());
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 { error: fetchErrorMessage(err), body: "", contentType: "" };
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 { error: "Failed to fetch URL: the redirect has an invalid Location.", body: "", contentType: "" };
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 { error: redirectReason, body: "", contentType: "" };
899
+ if (!redirectAllowed) return emptyResult(redirectReason);
796
900
  const redirected = await resolveWithBudget(redirectHost);
797
- if (!redirected.ok) return { error: redirected.reason, body: "", contentType: "" };
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 = budgetExceededResult(deadline, signal, now);
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
- error: response.truncated
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 = budgetExceededResult(deadline, signal, now, contentType);
943
+ const budgetResult = checkBudget(contentType);
841
944
  if (budgetResult !== null) return budgetResult;
842
945
  if (!pdfText) pdfText = "(PDF contains no extractable text)";
843
- if (response.truncated) pdfText += TRUNCATED_BODY_NOTICE;
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
- error: `(non-text content: ${safeType}, ${response.body.length} bytes; not readable as text)`,
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
- error: `(binary content, ${response.body.length} bytes; not readable as text)`,
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
- ) + (response.truncated ? TRUNCATED_BODY_NOTICE : "");
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
- if (response.truncated) alt += TRUNCATED_BODY_NOTICE;
999
+ alt = withTruncation(alt, Boolean(response.truncated));
883
1000
  return { error: null, body: alt, contentType };
884
1001
  }
885
- return {
886
- error: `(binary content, ${response.body.length} bytes; not readable as text)`,
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 { error: "Failed to fetch URL: too many redirects.", body: "", contentType: "" };
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
- return last >= 0xd800 && last <= 0xdbff ? sliced.slice(0, -1) : sliced;
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
- if (html.endsWith(TRUNCATED_BODY_SUFFIX) && !converted.endsWith(TRUNCATED_BODY_SUFFIX)) {
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
- return truncatePageText(
1081
- `README of ${url} (fetched via the GitHub README API):\n\n` + apiBody,
1082
- maxChars,
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
- return truncatePageText(
1091
- `README of ${url} (fetched via the GitHub raw README URL):\n\n` + rawBody,
1092
- maxChars,
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) return result.error;
1100
-
1101
- const isHtml = result.contentType.includes("html") || looksLikeHtml(result.body);
1102
- if (!isHtml) return truncatePageText(result.body.trim(), maxChars);
1103
-
1104
- return truncatePageText(pagePrefixedMarkdown(result.body), maxChars);
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
  }