pi-unsloth-webtools 0.3.1 → 0.4.0

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