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/README.md +14 -2
- package/extensions/web-search.ts +162 -73
- package/lib/adapters.ts +295 -117
- package/lib/apis.ts +2 -0
- package/lib/cache.ts +42 -16
- package/lib/engine.ts +489 -91
- package/lib/extract.ts +377 -61
- package/lib/fetcher.ts +443 -217
- package/lib/net.ts +93 -0
- package/lib/rank.ts +89 -16
- package/lib/safe.ts +94 -0
- package/lib/version.ts +1 -1
- package/package.json +20 -16
- package/themes/claude-dark.json +80 -0
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
|
|
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:
|
|
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
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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 (
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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: {
|
|
156
|
-
|
|
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
|
|
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:
|
|
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<
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
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} — ${
|
|
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
|
-
*
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
320
|
-
const
|
|
321
|
-
|
|
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<
|
|
548
|
+
async function smartFetchRaw(url: string, opts: FetchOptions): Promise<Extracted> {
|
|
328
549
|
const safeUrl = assertSafeUrl(url);
|
|
329
|
-
const cacheKey = `
|
|
330
|
-
const cached = opts.noCache ? null : (FETCH_CACHE.get(cacheKey) as
|
|
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:
|
|
339
|
-
text: ad.text
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
387
|
-
text,
|
|
388
|
-
status: res.status,
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
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 };
|