pi-webfind 0.5.2 → 0.6.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/lib/fetcher.ts CHANGED
@@ -4,19 +4,24 @@
4
4
  */
5
5
  import { createDiskBackedCache } from "./cache.ts";
6
6
  import { htmlToText } from "./engine.ts";
7
- import { htmlToMarkdown } from "./extract.ts";
8
- import { topPassages } from "./rank.ts";
9
- import { extractPdf, extractPdfViaPoppler } from "./pdf.ts";
7
+ import { extractDate, htmlToMarkdown } from "./extract.ts";
8
+ import { topPassages, type PickedPassage } from "./rank.ts";
9
+ import { extractPdf } from "./pdf.ts";
10
+ import { assertSafeUrl, resolveSafe } from "./safe.ts";
11
+ import { assertOnline, hostCooldownUntil, jinaGap, markOnline, noteNotFound, setHostCooldown, jinaAuth } from "./net.ts";
12
+ import { browserHeaders, storeCookies } from "./engine.ts";
10
13
 
11
14
  export const UA =
12
15
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
13
16
  // r.jina.ai blocks fake browser UAs but allows honest tool UAs (opposite of most sites)
14
17
  import { TOOL_UA } from "./version.ts";
15
18
 
16
- const FETCH_CACHE = createDiskBackedCache({ name: "fetch", maxEntries: 256, ttlMs: 60 * 60 * 1000 }); // 1h, survives restarts
19
+ const FETCH_CACHE = createDiskBackedCache({ name: "fetch", maxEntries: 64, ttlMs: 60 * 60 * 1000 }); // 1h, survives restarts
17
20
  const lastHitByHost = new Map<string, number>();
18
21
  const MAX_BYTES = 3 * 1024 * 1024; // read at most 3MB
19
22
  const DEFAULT_TIMEOUT = 15_000;
23
+ /** Extraction window for the full-text cache: one entry per URL holds up to this many chars. */
24
+ const EXTRACT_CAP = 200_000;
20
25
 
21
26
  export interface FetchOptions {
22
27
  /** Optional query — when set, return the intro + query-relevant passages instead of the page head. */
@@ -31,6 +36,12 @@ export interface FetchOptions {
31
36
  noCache?: boolean;
32
37
  /** Return 4xx/5xx responses (with body) instead of throwing — useful for API status checks (e.g. 404 = name available). */
33
38
  allowHttpErrors?: boolean;
39
+ /** opt out of the r.jina.ai reader fallback for this call (default: enabled) */
40
+ jinaEnabled?: boolean;
41
+ /** query forwarded to the reader proxy (X-Query header) for targeted extraction */
42
+ jinaQuery?: string;
43
+ /** char offset into the extracted document; head view only (ignored with query/raw) */
44
+ offset?: number;
34
45
  signal?: AbortSignal;
35
46
  }
36
47
 
@@ -42,6 +53,7 @@ export interface FetchResult {
42
53
  source:
43
54
  | "direct"
44
55
  | "wayback"
56
+ | "archive-ph"
45
57
  | "jina"
46
58
  | "github-api"
47
59
  | "github-issue-api"
@@ -56,44 +68,31 @@ export interface FetchResult {
56
68
  waybackDate?: string;
57
69
  truncated: boolean;
58
70
  fromCache: boolean;
71
+ /** publication date (YYYY-MM-DD) from meta/JSON-LD/<time>/URL path, when found */
72
+ date?: string;
73
+ /** extracted chars before any slicing (≤ EXTRACT_CAP); set on the head view */
74
+ totalChars?: number;
75
+ /** start offset of `text` within the extracted document (head view) */
76
+ offset?: number;
77
+ /** provenance notes ("jina skipped: custom headers", "body capped at 3MB", "redirected 2×") */
78
+ notes?: string[];
79
+ /** ranked passages (incl. scores) from query-aware extraction — set when opts.query was given */
80
+ passages?: PickedPassage[];
59
81
  }
60
82
 
61
- // ------------------------------------------------------------- SSRF guard
62
-
63
- const BLOCKED_HOST_PATTERNS = [
64
- /^localhost$/i,
65
- /^127\./,
66
- /^10\./,
67
- /^192\.168\./,
68
- /^172\.(1[6-9]|2\d|3[01])\./,
69
- /^169\.254\./,
70
- /^0\./,
71
- /\.local$/i,
72
- /^\[?::1\]?$/,
73
- /^\[?fc00:/i,
74
- /^\[?fe80:/i,
75
- /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // CGNAT
76
- ];
77
-
78
- export function assertSafeUrl(rawUrl: string): URL {
79
- let url: URL;
80
- try {
81
- url = new URL(rawUrl);
82
- } catch {
83
- throw new Error(`Invalid URL: ${rawUrl}`);
84
- }
85
- if (!/^https?:$/.test(url.protocol)) throw new Error(`Blocked protocol: ${url.protocol} (use http/https)`);
86
- const host = url.hostname;
87
- for (const p of BLOCKED_HOST_PATTERNS) if (p.test(host)) throw new Error(`Blocked host (SSRF protection): ${host}`);
88
- return url;
89
- }
83
+ // --------------------------------------------------------------- SSRF guard
84
+ // async address-level checks live in lib/safe.ts; assertSafeUrl is re-exported
85
+ // from there (sync subset for cache keys / adapter routing).
86
+
87
+ export { assertSafeUrl };
90
88
 
91
89
  // ------------------------------------------------------------------- helpers
92
90
 
93
- function politeDelay(host: string, signal?: AbortSignal): Promise<void> {
91
+ function politeDelay(host: string, signal?: AbortSignal, deadlineAt = Number.POSITIVE_INFINITY): Promise<void> {
94
92
  return new Promise((resolve) => {
93
+ if (signal?.aborted) return resolve(); // never sleep past an aborted signal
95
94
  const prev = lastHitByHost.get(host) ?? 0;
96
- const wait = Math.max(0, prev + 700 - Date.now());
95
+ const wait = Math.max(0, Math.min(prev + 700 - Date.now(), Math.max(0, deadlineAt - Date.now())));
97
96
  lastHitByHost.set(host, Date.now() + wait);
98
97
  if (wait === 0) return resolve();
99
98
  const t = setTimeout(resolve, wait);
@@ -117,46 +116,122 @@ async function rawFetch(
117
116
  url: URL,
118
117
  opts: FetchOptions,
119
118
  extraHeaders: Record<string, string> = {},
120
- ): Promise<{ res: Response; bodyText: string; bytes: Buffer }> {
121
- const timeout = AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT);
122
- const combined = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout;
123
- await politeDelay(url.host, opts.signal);
124
- const res = await fetch(url, {
125
- headers: {
126
- "User-Agent": UA,
127
- Accept: "text/html,application/xhtml+xml,application/json,text/plain;q=0.9,*/*;q=0.5",
128
- "Accept-Language": "en-US,en;q=0.9",
129
- ...extraHeaders,
130
- ...(opts.headers ?? {}),
131
- },
132
- redirect: "follow",
133
- signal: combined,
134
- });
119
+ ): Promise<{ res: Response; bodyText: string; bytes: Buffer; finalUrl: string; capped: boolean; hops: number }> {
120
+ // one deadline for the whole call — the signal smartFetchRaw built already
121
+ // carries the timeout; no per-attempt timer here
122
+ const signal = opts.signal ?? AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT);
123
+ let current = await resolveSafe(url);
124
+ let custom = opts.headers ?? {};
125
+ let hops = 0;
126
+ for (let hop = 0; ; hop++) {
127
+ await politeDelay(current.host, opts.signal, Number(opts.timeoutMs) || Number.POSITIVE_INFINITY);
128
+ const res = await fetch(current, {
129
+ headers: {
130
+ ...browserHeaders(current.host, { accept: "text/html,application/xhtml+xml,application/json,text/plain;q=0.9,*/*;q=0.5" }),
131
+ ...extraHeaders,
132
+ ...custom,
133
+ },
134
+ redirect: "manual",
135
+ signal,
136
+ });
137
+ storeCookies(current.host, res);
138
+ const loc = res.headers.get("location");
139
+ if (res.status >= 300 && res.status < 400 && loc) {
140
+ await res.body?.cancel();
141
+ if (hop >= MAX_REDIRECTS) throw new Error(`Too many redirects (>${MAX_REDIRECTS}) from ${url.href}`);
142
+ const next = await resolveSafe(new URL(loc, current)); // throws Blocked host → no retry, no jina
143
+ if (next.host !== current.host) custom = {}; // drop Authorization etc. across hosts, like browsers
144
+ current = next;
145
+ hops++;
146
+ continue;
147
+ }
148
+ const { bytes, capped } = await readCapped(res);
149
+ const bodyText = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
150
+ return { res, bodyText, bytes, finalUrl: current.href, capped, hops };
151
+ }
152
+ }
153
+
154
+ const MAX_REDIRECTS = 5;
155
+ const SKIP_BODY = /^(image|video|audio|font)\/|^application\/(zip|gzip|x-tar|octet-stream|wasm|sqlite|x-7z-compressed|x-rar)/;
156
+
157
+ /** Stream the body with a hard cap; skip the download entirely for binary types. */
158
+ async function readCapped(res: Response): Promise<{ bytes: Buffer; capped: boolean }> {
159
+ const ct = (res.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase();
135
160
  const len = Number(res.headers.get("content-length") ?? 0);
136
- if (len > MAX_BYTES) throw new Error(`Response too large: ${(len / 1e6).toFixed(1)}MB`);
137
- const buf = await res.arrayBuffer();
138
- const bytes = Buffer.from(buf);
139
- const bodyText = new TextDecoder("utf-8", { fatal: false }).decode(bytes.subarray(0, MAX_BYTES));
140
- return { res, bodyText, bytes };
161
+ if (ct && SKIP_BODY.test(ct)) {
162
+ await res.body?.cancel();
163
+ return { bytes: Buffer.alloc(0), capped: false };
164
+ }
165
+ if (len > MAX_BYTES) {
166
+ await res.body?.cancel();
167
+ throw new Error(`Response too large: ${(len / 1e6).toFixed(1)}MB`);
168
+ }
169
+ if (!res.body) return { bytes: Buffer.alloc(0), capped: false };
170
+ const reader = res.body.getReader();
171
+ const chunks: Uint8Array[] = [];
172
+ let received = 0;
173
+ for (;;) {
174
+ const { done, value } = await reader.read();
175
+ if (done) break;
176
+ if (received + value.byteLength > MAX_BYTES) {
177
+ chunks.push(value.subarray(0, MAX_BYTES - received));
178
+ received = MAX_BYTES;
179
+ await reader.cancel();
180
+ return { bytes: Buffer.concat(chunks, received), capped: true };
181
+ }
182
+ chunks.push(value);
183
+ received += value.byteLength;
184
+ }
185
+ return { bytes: Buffer.concat(chunks, received), capped: false };
141
186
  }
142
187
 
143
188
  // ---------------------------------------------------- jina reader proxy
144
189
 
145
- let lastJinaFetch = 0;
146
- async function jinaFetchText(url: URL, opts: FetchOptions): Promise<FetchResult | null> {
147
- if (opts.jinaEnabled === false || opts.raw) return null;
190
+ const SECRET_PARAM = /^(token|key|api[_-]?key|auth|authorization|sig|signature|secret|password|access[_-]?token|code|session|jwt)$/i;
191
+ const NON_HTML_PATH = /\.(json|xml|txt|csv|pdf|md|yaml|yml|rss|atom)(\?|$)/i;
192
+
193
+ /** null = allowed; otherwise the reason, recorded in FetchResult.notes. */
194
+ export function jinaBlockReason(url: URL, opts: FetchOptions, contentType?: string): string | null {
195
+ if (opts.jinaEnabled === false) return "disabled";
196
+ if (opts.raw) return "raw";
197
+ if (opts.headers && Object.keys(opts.headers).length > 0) return "custom headers";
198
+ for (const k of url.searchParams.keys()) if (SECRET_PARAM.test(k)) return `secret-looking param '${k}'`;
199
+ if (contentType !== undefined) {
200
+ const ct = contentType.split(";")[0]!.trim().toLowerCase();
201
+ if (ct && !/html/.test(ct)) return `content-type ${ct}`;
202
+ } else if (NON_HTML_PATH.test(url.pathname + url.search)) {
203
+ return "non-HTML path";
204
+ }
205
+ return null;
206
+ }
207
+
208
+ async function jinaFetchText(url: URL, opts: FetchOptions, contentType?: string): Promise<Extracted | null> {
209
+ const why = jinaBlockReason(url, opts, contentType);
210
+ if (why) return null; // blocked — callers surface the reason via jinaBlockReason when needed
148
211
  try {
149
- const wait = lastJinaFetch + 3_500 - Date.now();
150
- if (wait > 0) await new Promise((r) => setTimeout(r, wait));
151
- lastJinaFetch = Date.now();
152
- const timeout = AbortSignal.timeout(30_000);
153
- const combined = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout;
212
+ assertOnline();
213
+ await jinaGap(opts.signal);
154
214
  const res = await fetch(`https://r.jina.ai/${url.href}`, {
155
- headers: { "User-Agent": TOOL_UA, Accept: "text/plain" },
156
- signal: combined,
215
+ headers: {
216
+ "User-Agent": TOOL_UA,
217
+ Accept: "text/plain",
218
+ ...jinaAuth(),
219
+ ...(opts.jinaQuery ? { "X-Query": opts.jinaQuery } : {}),
220
+ },
221
+ signal: opts.signal,
157
222
  });
158
223
  if (!res.ok) return null;
224
+ markOnline();
159
225
  let text = await res.text();
226
+ // jina 200s even when the ORIGIN 404'd — it prepends "Warning: Target URL
227
+ // returned error 404"; treat those bodies as failures so callers surface
228
+ // the real status instead of caching error-page text
229
+ const originErr = text.match(/^Warning: Target URL returned error (\d{3})/m);
230
+ if (originErr) {
231
+ const err = new Error(`HTTP ${originErr[1]}`) as Error & { status?: number };
232
+ err.status = Number(originErr[1]);
233
+ throw err;
234
+ }
160
235
  if (text.trim().length < 40) return null;
161
236
  // jina sometimes returns the block-page itself — detect and reject
162
237
  const hardBlock = /You've been blocked|blocked by network security|log in to your (developer token|Reddit account)/i.test(
@@ -173,13 +248,14 @@ async function jinaFetchText(url: URL, opts: FetchOptions): Promise<FetchResult
173
248
  if (body.replace(/\s+/g, " ").trim().length < 600) return null;
174
249
  }
175
250
  return {
176
- text: text.slice(0, opts.maxChars),
251
+ text,
177
252
  status: 200,
178
253
  finalUrl: url.href,
179
254
  contentType: "text/markdown (via r.jina.ai)",
180
255
  source: "jina",
181
- truncated: text.length > opts.maxChars,
256
+ truncated: false,
182
257
  fromCache: false,
258
+ at: Date.now(),
183
259
  };
184
260
  } catch {
185
261
  return null;
@@ -188,26 +264,55 @@ async function jinaFetchText(url: URL, opts: FetchOptions): Promise<FetchResult
188
264
 
189
265
  // -------------------------------------------------------------- wayback
190
266
 
267
+ /** Paywall escape hatch: archive.ph (archive.today) mirrors many metered pages Wayback misses. */
268
+ async function archivePhFetch(url: URL, opts: FetchOptions): Promise<Extracted | null> {
269
+ if (opts.waybackEnabled === false) return null;
270
+ if (opts.headers && Object.keys(opts.headers).length > 0) return null; // never leak auth to the mirror
271
+ try {
272
+ assertOnline();
273
+ const mirror = new URL(`https://archive.ph/newest/${url.href}`);
274
+ const { res: sres, bodyText, bytes } = await rawFetch(mirror, { ...opts, headers: undefined });
275
+ if (!sres.ok) return null;
276
+ const { text } = await extract(mirror, sres.headers.get("content-type") ?? "", bodyText, { ...opts, maxChars: EXTRACT_CAP }, bytes, Number(sres.headers.get("content-length") ?? 0));
277
+ return {
278
+ text,
279
+ status: 200,
280
+ finalUrl: mirror.href,
281
+ contentType: sres.headers.get("content-type") ?? "text/html",
282
+ source: "archive-ph",
283
+ truncated: false,
284
+ fromCache: false,
285
+ at: Date.now(),
286
+ };
287
+ } catch {
288
+ return null; // archive.ph is flaky under load — best effort only
289
+ }
290
+ }
291
+
191
292
  async function waybackFetch(
192
293
  url: URL,
193
294
  opts: FetchOptions,
194
- ): Promise<FetchResult | null> {
295
+ ): Promise<Extracted | null> {
195
296
  if (opts.waybackEnabled === false) return null;
297
+ // An authenticated URL has no useful public snapshot, and the availability
298
+ // API call would leak the (possibly secret-bearing) URL to archive.org.
299
+ if (opts.headers && Object.keys(opts.headers).length > 0) return null;
196
300
  try {
301
+ assertOnline();
197
302
  const api = new URL("https://archive.org/wayback/available");
198
303
  api.searchParams.set("url", url.href);
199
- const timeout = AbortSignal.timeout(DEFAULT_TIMEOUT);
200
- const combined = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout;
201
- const res = await fetch(api, { headers: { "User-Agent": UA }, signal: combined });
304
+ const res = await fetch(api, { headers: { "User-Agent": UA }, signal: opts.signal });
202
305
  const data = (await res.json()) as {
203
306
  archived_snapshots?: { closest?: { url: string; timestamp: string } };
204
307
  };
205
308
  const snap = data.archived_snapshots?.closest;
206
309
  if (!snap) return null;
310
+ markOnline();
207
311
  const snapUrl = new URL(snap.url);
208
- const { res: sres, bodyText, bytes: bytes2 } = await rawFetch(snapUrl, opts);
312
+ // headers: undefined custom headers (Authorization etc.) never reach archive.org
313
+ const { res: sres, bodyText, bytes: bytes2 } = await rawFetch(snapUrl, { ...opts, headers: undefined });
209
314
  if (!sres.ok) return null;
210
- const { text, truncated } = extract(snapUrl, sres.headers.get("content-type") ?? "", bodyText, opts, bytes2);
315
+ const { text } = await extract(snapUrl, sres.headers.get("content-type") ?? "", bodyText, { ...opts, maxChars: EXTRACT_CAP }, bytes2, Number(sres.headers.get("content-length") ?? 0));
211
316
  return {
212
317
  text,
213
318
  status: 200,
@@ -215,46 +320,45 @@ async function waybackFetch(
215
320
  contentType: sres.headers.get("content-type") ?? "text/html",
216
321
  source: "wayback",
217
322
  waybackDate: snap.timestamp,
218
- truncated,
323
+ truncated: false,
219
324
  fromCache: false,
325
+ at: Date.now(),
220
326
  };
221
327
  } catch {
222
328
  return null;
223
329
  }
224
330
  }
225
331
 
226
- // -------------------------------------------------------------- extraction
227
-
228
332
  const BINARY_TYPES = /^(image\/|video\/|audio\/|application\/(zip|gzip|x-tar|pdf|octet-stream|wasm|sqlite))/;
229
- function extract(
333
+ async function extract(
230
334
  url: URL,
231
335
  contentType: string,
232
336
  body: string,
233
337
  opts: FetchOptions,
234
338
  bytes?: Buffer,
235
- ): { text: string; truncated: boolean } {
339
+ contentLength?: number,
340
+ ): Promise<{ text: string; truncated: boolean; date?: string }> {
236
341
  const ct = contentType.split(";")[0].trim().toLowerCase();
237
342
 
238
343
  // PDF → text extraction (poppler if installed, else internal parser)
239
344
  if (bytes && (ct === "application/pdf" || /\.pdf($|\?)/i.test(url.pathname))) {
240
345
  try {
241
- const pdfText = extractPdfSync(bytes);
242
- if (pdfText) {
243
- const collapsed = pdfText.replace(/\n{3,}/g, "\n\n");
244
- return { text: collapsed.slice(0, opts.maxChars), truncated: collapsed.length > opts.maxChars };
245
- }
246
- } catch {
346
+ const pdfText = await extractPdf(bytes);
347
+ const collapsed = pdfText.replace(/\n{3,}/g, "\n\n");
348
+ return { text: collapsed.slice(0, opts.maxChars), truncated: collapsed.length > opts.maxChars };
349
+ } catch (err) {
247
350
  return {
248
- text: `[PDF detected (${bytes.length} bytes) but text extraction failed likely scanned/image-only or encrypted]`,
351
+ text: `[PDF detected (${bytes.length} bytes) but text extraction failed: ${(err as Error).message}]`,
249
352
  truncated: false,
250
353
  };
251
354
  }
252
355
  }
253
356
 
254
- // Binary content: metadata only
357
+ // Binary content: metadata only (body is not downloaded for these types)
255
358
  if (BINARY_TYPES.test(ct)) {
359
+ const size = contentLength !== undefined && contentLength > 0 ? `${(contentLength / 1e6).toFixed(1)}MB` : "size unknown";
256
360
  return {
257
- text: `[binary content: ${ct} — ${body.length} bytes received, not text-extractable]`,
361
+ text: `[binary content: ${ct} — Content-Length ${size}, not downloaded]`,
258
362
  truncated: false,
259
363
  };
260
364
  }
@@ -277,57 +381,174 @@ function extract(
277
381
 
278
382
  // HTML → readable text
279
383
  const format = opts.format ?? "markdown";
384
+ const date = extractDate(body, url.href);
280
385
  if (format === "markdown") {
281
386
  try {
282
387
  const md = htmlToMarkdown(body, url.href, opts.maxChars);
283
- if (md.text) return md; // junk check inside; fall through on failure
388
+ if (md.text) return { ...md, date }; // junk check inside; fall through on failure
284
389
  } catch {
285
390
  /* fall through to flattener */
286
391
  }
287
392
  }
288
393
  const { text, truncated } = htmlToText(body, opts.maxChars);
394
+ const withDate = { date };
289
395
  if (text.length < 200 && body.length > 5000 && /<app-root|<div id="root"|<div id="app"|ng-app|data-reactroot|__NEXT_DATA__|window\.__INITIAL_STATE__|shreddit|<web-app/i.test(body)) {
290
396
  return {
291
397
  text: text + "\n\n[page appears to be a client-rendered SPA — little static text available]",
292
398
  truncated,
399
+ ...withDate,
293
400
  };
294
401
  }
295
402
  if (text.length < 200 && body.length > 20000) {
296
403
  return {
297
404
  text: text + "\n\n[very little readable text extracted from a large page — likely JS-rendered or bot-walled; try the Wayback fallback with no_wayback=false, or a different URL]",
298
405
  truncated,
406
+ ...withDate,
299
407
  };
300
408
  }
301
- return { text, truncated };
409
+ return { text, truncated, ...withDate };
302
410
  }
303
411
 
304
412
  // ------------------------------------------------------------------- main
305
413
 
306
414
  /**
307
- * Query-aware wrapper: fetches with a wide extraction window, then ranks
308
- * passages against opts.query (lib/rank.ts) down to opts.maxChars. Applied
309
- * AFTER all fallback paths so wayback/jina results benefit equally.
415
+ * Error message with the cause's errno code appended: "fetch failed (ECONNREFUSED)".
310
416
  */
417
+ export function describeError(err: unknown): string {
418
+ const msg = String((err as Error)?.message ?? err);
419
+ const code =
420
+ (err as { cause?: { code?: string } })?.cause?.code ?? (err as { code?: string })?.code;
421
+ return code && !msg.includes(code) ? `${msg} (${code})` : msg;
422
+ }
423
+
424
+ type ErrClass = "permanent" | "notfound" | "auth" | "ratelimit" | "transient";
425
+
426
+ /** Bucket an error or HTTP status into the retry-policy class. */
427
+ function classify(x: number | unknown): ErrClass {
428
+ if (typeof x === "number") {
429
+ if (x === 401 || x === 403) return "auth";
430
+ if (x === 429) return "ratelimit";
431
+ if (x === 404 || x === 410) return "notfound";
432
+ return "transient"; // 5xx and odd 4xx
433
+ }
434
+ const msg = String((x as Error)?.message ?? x);
435
+ if (/Blocked (protocol|host)|Invalid URL|too large|Too many redirects|DNS lookup failed/.test(msg)) return "permanent";
436
+ if ((x as Error)?.name === "AbortError") return "permanent";
437
+ return "transient";
438
+ }
439
+
440
+ /** Retry-After header (seconds or HTTP-date) in ms. */
441
+ function retryAfterMs(v: string | null): number | undefined {
442
+ if (!v) return undefined;
443
+ const s = Number(v);
444
+ if (Number.isFinite(s) && s >= 0) return s * 1000;
445
+ const d = Date.parse(v);
446
+ return Number.isNaN(d) ? undefined : Math.max(0, d - Date.now());
447
+ }
448
+
449
+ function httpMessage(host: string, status: number): string {
450
+ if (status === 401 || status === 403) return `HTTP ${status} — ${host} refuses unauthenticated requests`;
451
+ if (status === 429) return `HTTP 429 — ${host} rate-limits us`;
452
+ return `HTTP ${status}`;
453
+ }
454
+
455
+ /** Hosts that always wall unauthenticated scrapers — jina relay is skipped for them (reddit policy). */
456
+ const KNOWN_WALLS = [/(^|\.)reddit\.com$/];
457
+
458
+ /** What the disk cache stores: one entry per URL, independent of maxChars/query/offset. */
459
+ interface Extracted {
460
+ text: string;
461
+ status: number;
462
+ finalUrl: string;
463
+ contentType: string;
464
+ source: FetchResult["source"];
465
+ waybackDate?: string;
466
+ notes?: string[];
467
+ truncated?: boolean;
468
+ fromCache?: boolean;
469
+ date?: string;
470
+ at: number;
471
+ }
472
+
473
+ function headView(text: string, opts: FetchOptions): { body: string; truncated: boolean } {
474
+ const totalChars = text.length;
475
+ const start = Math.max(0, Math.floor(opts.offset ?? 0));
476
+ if (start >= totalChars) {
477
+ return { body: `[offset ${start} is past the end (${totalChars} chars)]`, truncated: false };
478
+ }
479
+ // budgets <600 never get a truncation footer (E5: a tiny query+maxChars call
480
+ // must not come back all footer) — they simply end at maxChars
481
+ if (opts.maxChars < 600) {
482
+ const end = Math.min(start + opts.maxChars, totalChars);
483
+ return { body: text.slice(start, end), truncated: end < totalChars };
484
+ }
485
+ const budget = Math.max(1, opts.maxChars - 120); // footer reserve
486
+ const end = Math.min(start + budget, totalChars);
487
+ const truncated = end < totalChars;
488
+ if (!truncated) return { body: text.slice(start, end), truncated: false };
489
+ const footer = `\n\n[truncated at ${end} of ${totalChars} chars — pass offset:${end} for the next part, or a query]`;
490
+ // footer lives INSIDE the budget (E3): body = budget − footer.length, total ≤ maxChars
491
+ const bodyEnd = Math.min(end, start + Math.max(1, budget - footer.length));
492
+ const footerText = `\n\n[truncated at ${bodyEnd} of ${totalChars} chars — pass offset:${bodyEnd} for the next part, or a query]`;
493
+ return { body: text.slice(start, bodyEnd) + footerText, truncated: true };
494
+ }
495
+
311
496
  export async function smartFetch(url: string, opts: FetchOptions): Promise<FetchResult> {
312
- const wide = opts.query?.trim() && !opts.raw ? Math.max(opts.maxChars * 8, 40_000) : opts.maxChars;
313
- const result = await smartFetchRaw(url, { ...opts, maxChars: wide });
314
- if (!opts.query?.trim() || opts.raw) return result;
315
- const { picked, total } = topPassages(result.text, opts.query, opts.maxChars, 600);
316
- if (picked.length === 0) {
317
- return { ...result, text: result.text.slice(0, opts.maxChars), truncated: result.text.length > opts.maxChars };
497
+ // full-text cache: one entry per URL — the view (head / query / offset) is
498
+ // derived per call from the stored extraction
499
+ const extracted = await smartFetchRaw(url, { ...opts });
500
+ const base: FetchResult = {
501
+ text: extracted.text,
502
+ status: extracted.status,
503
+ finalUrl: extracted.finalUrl,
504
+ contentType: extracted.contentType,
505
+ source: extracted.source,
506
+ ...(extracted.waybackDate ? { waybackDate: extracted.waybackDate } : {}),
507
+ ...(extracted.date ? { date: extracted.date } : {}),
508
+ notes: extracted.notes,
509
+ truncated: false,
510
+ fromCache: extracted.fromCache === true,
511
+ };
512
+ const totalChars = extracted.text.length;
513
+
514
+ if (opts.raw || extracted.source === "jina") {
515
+ // raw and jina views slice directly (jina text is already reader-formatted)
516
+ if (opts.raw) {
517
+ const start = Math.max(0, Math.floor(opts.offset ?? 0));
518
+ if (start >= totalChars) {
519
+ return { ...base, text: `[offset ${start} is past the end (${totalChars} chars)]`, truncated: false, totalChars, offset: start };
520
+ }
521
+ const body = extracted.text.slice(start, start + opts.maxChars);
522
+ return { ...base, text: body, truncated: start + opts.maxChars < totalChars, totalChars, offset: start };
523
+ }
524
+ }
525
+ if (opts.query?.trim() && !opts.raw) {
526
+ const { picked, total, passages } = topPassages(extracted.text, opts.query, opts.maxChars, 600);
527
+ if (picked.length === 0) {
528
+ const { body } = headView(extracted.text, opts);
529
+ return { ...base, text: body, truncated: totalChars > opts.maxChars, totalChars, offset: opts.offset ?? 0, passages };
530
+ }
531
+ const parts: string[] = [];
532
+ let prevHeading: string | undefined;
533
+ for (const p of picked) {
534
+ parts.push(p.heading && p.heading !== prevHeading ? `## ${p.heading}\n${p.text}` : p.text);
535
+ prevHeading = p.heading;
536
+ }
537
+ const footer = `\n\n[${picked.length} of ${total} passages shown — most relevant to the query. Omit query for the page head; offset pages it.]`;
538
+ let body = parts.join("\n\n");
539
+ const truncated = body.length + footer.length > opts.maxChars;
540
+ if (truncated) body = body.slice(0, Math.max(opts.maxChars - footer.length, 0));
541
+ return { ...base, text: body + footer, truncated, totalChars, offset: opts.offset ?? 0, passages };
318
542
  }
319
- const parts = picked.map((p) => (p.heading ? `## ${p.heading}\n${p.text}` : p.text));
320
- const footer = `\n\n[${picked.length} of ${total} passages shown — most relevant to the query. Omit query for the page head.]`;
321
- let body = parts.join("\n\n");
322
- const truncated = body.length + footer.length > opts.maxChars;
323
- if (truncated) body = body.slice(0, Math.max(opts.maxChars - footer.length, 0));
324
- return { ...result, text: body + footer, truncated };
543
+ // head view with optional offset
544
+ const { body, truncated } = headView(extracted.text, opts);
545
+ return { ...base, text: body, truncated, totalChars, offset: Math.max(0, Math.floor(opts.offset ?? 0)) };
325
546
  }
326
547
 
327
- async function smartFetchRaw(url: string, opts: FetchOptions): Promise<FetchResult> {
548
+ async function smartFetchRaw(url: string, opts: FetchOptions): Promise<Extracted> {
328
549
  const safeUrl = assertSafeUrl(url);
329
- const cacheKey = `f:${opts.raw ? "raw" : opts.maxChars}:${opts.query ?? ""}:${opts.headers ? JSON.stringify(opts.headers) : ""}:${safeUrl.href}`;
330
- const cached = opts.noCache ? null : (FETCH_CACHE.get(cacheKey) as FetchResult | null);
550
+ const cacheKey = `x:${opts.raw ? "raw" : "md"}:${opts.headers ? JSON.stringify(opts.headers) : ""}:${safeUrl.href}`;
551
+ const cached = opts.noCache ? null : (FETCH_CACHE.get(cacheKey) as Extracted | null);
331
552
  if (cached) return { ...cached, fromCache: true };
332
553
 
333
554
  // site adapters: known URL shapes route to their clean API (github/so/hn/reddit/wikipedia)
@@ -335,141 +556,146 @@ async function smartFetchRaw(url: string, opts: FetchOptions): Promise<FetchResu
335
556
  const { trySiteAdapter } = await import("./adapters.ts");
336
557
  const ad = await trySiteAdapter(safeUrl.href, opts.signal).catch(() => null);
337
558
  if (ad) {
338
- const out: FetchResult = {
339
- text: ad.text.slice(0, opts.maxChars),
559
+ const out: Extracted = {
560
+ text: ad.text,
340
561
  status: 200,
341
562
  finalUrl: safeUrl.href,
342
563
  contentType: "text/markdown",
343
564
  source: ad.source as any,
344
- truncated: ad.text.length > opts.maxChars,
565
+ truncated: false,
345
566
  fromCache: false,
567
+ ...(ad.date ? { date: ad.date } : {}),
568
+ at: Date.now(),
346
569
  };
347
570
  FETCH_CACHE.set(cacheKey, out);
348
571
  return out;
349
572
  }
350
573
  }
351
574
 
352
- // retry with exponential backoff on transient failures
575
+ const deadlineAt = Date.now() + (opts.timeoutMs ?? DEFAULT_TIMEOUT);
576
+ const remaining = () => deadlineAt - Date.now();
577
+ const overall = opts.signal
578
+ ? AbortSignal.any([opts.signal, AbortSignal.timeout(Math.max(1, remaining()))])
579
+ : AbortSignal.timeout(Math.max(1, remaining()));
580
+ const inner: FetchOptions = { ...opts, signal: overall, maxChars: EXTRACT_CAP };
581
+
582
+ // host rate-limited from an earlier call? skip straight to Wayback/jina
583
+ const cooldownLeft = hostCooldownUntil(safeUrl.host) - Date.now();
584
+ if (cooldownLeft > 0) {
585
+ const secs = Math.ceil(cooldownLeft / 1000);
586
+ if (remaining() > 2_000) {
587
+ const wb = await waybackFetch(safeUrl, inner);
588
+ if (wb) return store(cacheKey, wb, safeUrl);
589
+ }
590
+ const j = remaining() > 2_000 + 3_500 ? await jinaFetchText(safeUrl, inner) : null;
591
+ if (j) return store(cacheKey, j, safeUrl);
592
+ throw new Error(`HTTP 429 — ${safeUrl.host} rate-limits us; retry in ${secs}s`);
593
+ }
594
+
353
595
  let lastErr: unknown;
354
- for (let attempt = 0; attempt < 3; attempt++) {
596
+ let waybackTried = false;
597
+ for (let attempt = 0; attempt < 3 && remaining() > 0; attempt++) {
355
598
  try {
356
- const { res, bodyText, bytes } = await rawFetch(safeUrl, opts);
357
- if ([401, 403, 429, 503].includes(res.status)) {
358
- const wb = await waybackFetch(safeUrl, opts);
359
- if (wb) {
360
- FETCH_CACHE.set(cacheKey, wb);
361
- return wb;
599
+ const { res, bodyText, bytes, finalUrl, capped, hops } = await rawFetch(safeUrl, inner);
600
+ markOnline();
601
+ const notes: string[] = [];
602
+ if (capped) notes.push("body capped at 3MB");
603
+ if (hops > 0) notes.push(`redirected ${hops}×`);
604
+ if (res.ok) {
605
+ const ctHeader = res.headers.get("content-type") ?? "";
606
+ const { text, date } = await extract(safeUrl, ctHeader, bodyText, inner, bytes, Number(res.headers.get("content-length") ?? 0));
607
+ // Thin HTML (SPA/bot-wall that 200s) → jina for real rendered text
608
+ const looksThin = ctHeader.includes("html") && text.replace(/\s+/g, " ").trim().length < 400 && !opts.raw;
609
+ if (looksThin && remaining() > 5_500) {
610
+ const why = jinaBlockReason(safeUrl, opts, ctHeader);
611
+ if (why && why !== "disabled" && why !== "raw") notes.push(`jina skipped: ${why}`);
612
+ const jina = await jinaFetchText(safeUrl, inner, ctHeader);
613
+ if (jina && jina.text.replace(/\s+/g, " ").trim().length > text.replace(/\s+/g, " ").trim().length) {
614
+ jina.notes = notes;
615
+ return store(cacheKey, jina, safeUrl);
616
+ }
362
617
  }
363
- if (opts.allowHttpErrors) {
364
- const { text, truncated } = extract(safeUrl, res.headers.get("content-type") ?? "", bodyText, opts, bytes);
365
- return { text, status: res.status, finalUrl: res.url || safeUrl.href, contentType: res.headers.get("content-type") ?? "", source: "direct", truncated, fromCache: false };
618
+ // 200 but paywalled (short subscribe wall) → Wayback, then archive.ph
619
+ const looksPaywalled = res.status === 200 && ctHeader.includes("html") && !opts.raw &&
620
+ text.replace(/\s+/g, " ").trim().length < 1200 &&
621
+ /subscribe|paywall|sign in to read/i.test(text);
622
+ if (looksPaywalled && remaining() > 2_000 && opts.waybackEnabled !== false) {
623
+ const wb = await waybackFetch(safeUrl, inner) ?? await archivePhFetch(safeUrl, inner);
624
+ if (wb) {
625
+ wb.notes = [...notes, "paywall bypassed via mirror"];
626
+ return store(cacheKey, wb, safeUrl);
627
+ }
366
628
  }
367
- throw new Error(`HTTP ${res.status}${res.status === 403 ? " (bot protection?)" : ""}`);
629
+ const out: Extracted = { text, status: res.status, finalUrl, contentType: ctHeader, source: "direct", truncated: false, fromCache: false, notes, date, at: Date.now() };
630
+ return store(cacheKey, out, safeUrl, finalUrl);
368
631
  }
369
- if (!res.ok) {
370
- if (opts.allowHttpErrors) {
371
- const { text, truncated } = extract(safeUrl, res.headers.get("content-type") ?? "", bodyText, opts, bytes);
372
- return { text, status: res.status, finalUrl: res.url || safeUrl.href, contentType: res.headers.get("content-type") ?? "", source: "direct", truncated, fromCache: false };
373
- }
374
- throw new Error(`HTTP ${res.status}`);
632
+ const cls = classify(res.status);
633
+ if (cls === "ratelimit") {
634
+ const ra = retryAfterMs(res.headers.get("retry-after"));
635
+ setHostCooldown(safeUrl.host, Math.min(ra ?? 60_000, 300_000));
375
636
  }
376
- const { text, truncated } = extract(safeUrl, res.headers.get("content-type") ?? "", bodyText, opts, bytes);
377
- // Thin content (SPA/bot-wall that 200s) try jina for real rendered text
378
- const looksThin = text.replace(/\s+/g, " ").trim().length < 400 && !opts.raw;
379
- if (looksThin) {
380
- const jina = await jinaFetchText(safeUrl, opts);
381
- if (jina && jina.text.replace(/\s+/g, " ").trim().length > text.replace(/\s+/g, " ").trim().length) {
382
- FETCH_CACHE.set(cacheKey, jina);
383
- return jina;
384
- }
637
+ // wayback fallback for auth walls, rate limits and flaky 5xx/503
638
+ if ((cls === "auth" || cls === "ratelimit" || res.status === 503) && !waybackTried && remaining() > 2_000 && opts.waybackEnabled !== false) {
639
+ waybackTried = true;
640
+ const wb = await waybackFetch(safeUrl, inner);
641
+ if (wb) return store(cacheKey, wb, safeUrl);
385
642
  }
386
- const out: FetchResult = {
387
- text,
388
- status: res.status,
389
- finalUrl: res.url || safeUrl.href,
390
- contentType: res.headers.get("content-type") ?? "",
391
- source: "direct",
392
- truncated,
393
- fromCache: false,
394
- };
395
- FETCH_CACHE.set(cacheKey, out);
396
- return out;
643
+ if (opts.allowHttpErrors) {
644
+ const { text } = await extract(safeUrl, res.headers.get("content-type") ?? "", bodyText, inner, bytes);
645
+ return { text, status: res.status, finalUrl, contentType: res.headers.get("content-type") ?? "", source: "direct", truncated: false, fromCache: false, notes, at: Date.now() };
646
+ }
647
+ if (cls === "auth") {
648
+ // jina renders public pages fine even when the origin 401s anonymous hits
649
+ const jina = remaining() > 5_500 ? await jinaFetchText(safeUrl, inner) : null;
650
+ if (jina) return store(cacheKey, jina, safeUrl);
651
+ const wall = KNOWN_WALLS.some((re) => re.test(safeUrl.host));
652
+ throw new Error(`HTTP ${res.status} — ${safeUrl.host} refuses unauthenticated requests; Wayback has no snapshot; ${wall ? "no free path (reddit)" : "no free path"}`);
653
+ }
654
+ if (cls === "ratelimit") {
655
+ const secs = Math.ceil(Math.min(retryAfterMs(res.headers.get("retry-after")) ?? 60_000, 300_000) / 1000);
656
+ throw new Error(`HTTP 429 — ${safeUrl.host} rate-limits us; retry in ${secs}s`);
657
+ }
658
+ if (cls === "notfound") throw new Error(`HTTP 404 — ${safeUrl.host} has no such page`);
659
+ lastErr = new Error(httpMessage(safeUrl.host, res.status)); // 5xx → backoff and retry
397
660
  } catch (err) {
398
661
  lastErr = err;
399
- const msg = String((err as Error)?.message ?? err);
400
- // don't retry permanent errors
401
- if (/Blocked (protocol|host)|Invalid URL|too large/.test(msg)) throw err;
402
- if (opts.signal?.aborted) throw err;
403
- await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
662
+ let cls: ErrClass = classify(err);
663
+ const structured = (err as { code?: string })?.code ?? "";
664
+ // structured auth/ratelimit throws above are terminal even though classify()
665
+ // sees only the message text (which reads "HTTP 401/429 ...")
666
+ if (structured === "HTTP_401" || structured === "HTTP_403") cls = "auth";
667
+ if (structured === "HTTP_429") cls = "ratelimit";
668
+ if (cls !== "transient" || overall.aborted || opts.signal?.aborted) throw err;
669
+ const code = (err as { cause?: { code?: string } })?.cause?.code;
670
+ if (code === "ENOTFOUND" || code === "ENETUNREACH") noteNotFound(safeUrl.host);
404
671
  }
672
+ const backoff = 300 * 3 ** attempt;
673
+ if (remaining() < backoff + 500) break;
674
+ await new Promise<void>((resolve) => {
675
+ const t = setTimeout(resolve, backoff);
676
+ overall.addEventListener("abort", () => {
677
+ clearTimeout(t);
678
+ resolve();
679
+ }, { once: true });
680
+ });
681
+ }
682
+ // budget left → jina as the last leg
683
+ if (remaining() > 5_500) {
684
+ const jina = await jinaFetchText(safeUrl, inner);
685
+ if (jina) return store(cacheKey, jina, safeUrl);
405
686
  }
406
- // retry loop exhausted → jina as safety net (network errors, bot walls)
407
- const jina = await jinaFetchText(safeUrl, opts);
408
- if (jina) return jina;
409
687
  throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
410
688
  }
411
689
 
412
- export { decodeEntities };
413
-
414
- /** Sync wrapper: run the async extractor synchronously via spawnSync for poppler, zlib for internal. */
415
- import { spawnSync } from "node:child_process";
416
- import { inflateSync as _is, inflateRawSync as _irs } from "node:zlib";
417
-
418
- function extractPdfSync(bytes: Buffer): string | null {
419
- // poppler first (installed on this machine)
420
- const pdftotext = spawnSync("pdftotext", ["-", "-"], { input: bytes, maxBuffer: 20 * 1024 * 1024, timeout: 20_000 });
421
- if (pdftotext.status === 0 && pdftotext.stdout && pdftotext.stdout.toString().trim().length > 0) {
422
- return pdftotext.stdout.toString();
423
- }
424
- // internal: inflate FlateDecode streams + parse text operators (sync zlib)
425
- try {
426
- const streams = findStreamsInternal(bytes);
427
- const chunks: string[] = [];
428
- for (const st of streams) {
429
- if (/\/Filter\s*\[^\]]*FlateDecode/i.test(st.dict) || /\/Filter\s*\/Fl\b/i.test(st.dict)) {
430
- try {
431
- chunks.push(_is(bytes.subarray(st.start, st.end)).toString("latin1"));
432
- } catch {
433
- try { chunks.push(_irs(bytes.subarray(st.start, st.end)).toString("latin1")); } catch { /* skip */ }
434
- }
435
- } else if (!/\/Filter/.test(st.dict)) {
436
- chunks.push(bytes.subarray(st.start, st.end).toString("latin1"));
437
- }
438
- }
439
- if (chunks.length === 0) return null;
440
- // reuse operator parser from pdf.ts via dynamic import is async; inline a simple regex pass:
441
- const all = chunks.join("\n");
442
- const lines: string[] = [];
443
- for (const m of all.matchAll(/\((?:\\.|[^\\()])*\)\s*Tj|\[(?:[^\][]|\\.)*\]\s*TJ/g)) {
444
- for (const sm of m[0].matchAll(/\((?:\\.|[^\\()])*\)/g)) {
445
- lines.push(sm[0].slice(1, -1).replace(/\\([nrtbf])/g, (_x, c) => ({ n: "\n", r: "\r", t: "\t", b: "\b", f: "\f" } as Record<string, string>)[c] ?? c).replace(/\\([0-7]{1,3})/g, (_x, o) => String.fromCharCode(parseInt(o, 8))).replace(/\\(.)/g, "$1"));
446
- }
447
- }
448
- if (lines.length === 0) return null;
449
- return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
450
- } catch {
451
- return null;
690
+ /** Cache the extraction under its key (and the finalUrl key when redirected). */
691
+ function store(key: string, ex: Extracted, safeUrl: URL, finalUrl?: string): Extracted {
692
+ const done = { ...ex, fromCache: false, at: Date.now() };
693
+ FETCH_CACHE.set(key, done);
694
+ if (finalUrl && finalUrl !== safeUrl.href) {
695
+ const altKey = `${key.split(":").slice(0, 3).join(":")}:${finalUrl}`;
696
+ FETCH_CACHE.set(altKey, done);
452
697
  }
698
+ return { ...done, fromCache: false };
453
699
  }
454
700
 
455
- function findStreamsInternal(data: Buffer): Array<{ dict: string; start: number; end: number }> {
456
- const streams: Array<{ dict: string; start: number; end: number }> = [];
457
- const latin = data.toString("latin1");
458
- let pos = 0;
459
- while (true) {
460
- const s = latin.indexOf("stream", pos);
461
- if (s === -1) break;
462
- if ((s === 0 || !/[a-zA-Z]/.test(latin[s - 1])) && !latin.startsWith("endstream", s)) {
463
- const dictStart = latin.lastIndexOf("<<", s);
464
- const dict = dictStart >= 0 ? latin.slice(Math.max(dictStart, s - 600), s) : "";
465
- let body = s + 6;
466
- if (latin[body] === "\r") body++;
467
- if (latin[body] === "\n") body++;
468
- const e = latin.indexOf("endstream", body);
469
- if (e === -1) break;
470
- streams.push({ dict, start: body, end: e });
471
- pos = e + 9;
472
- } else pos = s + 6;
473
- }
474
- return streams;
475
- }
701
+ export { decodeEntities };