pi-webfind 0.5.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/LICENSE +21 -0
- package/README.md +126 -0
- package/extensions/web-search.ts +662 -0
- package/lib/adapters.ts +228 -0
- package/lib/apis.ts +221 -0
- package/lib/cache.ts +160 -0
- package/lib/engine.ts +418 -0
- package/lib/extract.ts +320 -0
- package/lib/fetcher.ts +475 -0
- package/lib/pdf.ts +181 -0
- package/lib/rank.ts +169 -0
- package/package.json +56 -0
package/lib/fetcher.ts
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart page fetcher: content-type aware, SSRF-guarded, with automatic
|
|
3
|
+
* Wayback Machine fallback when a site blocks us (401/403/429/503).
|
|
4
|
+
*/
|
|
5
|
+
import { createDiskBackedCache } from "./cache.ts";
|
|
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";
|
|
10
|
+
|
|
11
|
+
export const UA =
|
|
12
|
+
"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
|
+
// r.jina.ai blocks fake browser UAs but allows honest tool UAs (opposite of most sites)
|
|
14
|
+
const TOOL_UA = "pi-webfind/0.5 (free web research toolkit for pi coding agent; +https://github.com/jawwadzafar/pi-webfind)";
|
|
15
|
+
|
|
16
|
+
const FETCH_CACHE = createDiskBackedCache({ name: "fetch", maxEntries: 256, ttlMs: 60 * 60 * 1000 }); // 1h, survives restarts
|
|
17
|
+
const lastHitByHost = new Map<string, number>();
|
|
18
|
+
const MAX_BYTES = 3 * 1024 * 1024; // read at most 3MB
|
|
19
|
+
const DEFAULT_TIMEOUT = 15_000;
|
|
20
|
+
|
|
21
|
+
export interface FetchOptions {
|
|
22
|
+
/** Optional query — when set, return the intro + query-relevant passages instead of the page head. */
|
|
23
|
+
query?: string;
|
|
24
|
+
maxChars: number;
|
|
25
|
+
raw?: boolean;
|
|
26
|
+
/** "markdown" (default): structure-aware article extraction. "text": legacy flattener. */
|
|
27
|
+
format?: "markdown" | "text";
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
headers?: Record<string, string>;
|
|
30
|
+
waybackEnabled?: boolean;
|
|
31
|
+
noCache?: boolean;
|
|
32
|
+
/** Return 4xx/5xx responses (with body) instead of throwing — useful for API status checks (e.g. 404 = name available). */
|
|
33
|
+
allowHttpErrors?: boolean;
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface FetchResult {
|
|
38
|
+
text: string;
|
|
39
|
+
status: number;
|
|
40
|
+
finalUrl: string;
|
|
41
|
+
contentType: string;
|
|
42
|
+
source:
|
|
43
|
+
| "direct"
|
|
44
|
+
| "wayback"
|
|
45
|
+
| "jina"
|
|
46
|
+
| "github-api"
|
|
47
|
+
| "github-issue-api"
|
|
48
|
+
| "github-pr-api"
|
|
49
|
+
| "github-raw"
|
|
50
|
+
| "stackexchange-api"
|
|
51
|
+
| "hn-algolia"
|
|
52
|
+
| "reddit-json"
|
|
53
|
+
| "wikipedia-rest"
|
|
54
|
+
| "arxiv-pdf"
|
|
55
|
+
| "markdown";
|
|
56
|
+
waybackDate?: string;
|
|
57
|
+
truncated: boolean;
|
|
58
|
+
fromCache: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
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
|
+
}
|
|
90
|
+
|
|
91
|
+
// ------------------------------------------------------------------- helpers
|
|
92
|
+
|
|
93
|
+
function politeDelay(host: string, signal?: AbortSignal): Promise<void> {
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
const prev = lastHitByHost.get(host) ?? 0;
|
|
96
|
+
const wait = Math.max(0, prev + 700 - Date.now());
|
|
97
|
+
lastHitByHost.set(host, Date.now() + wait);
|
|
98
|
+
if (wait === 0) return resolve();
|
|
99
|
+
const t = setTimeout(resolve, wait);
|
|
100
|
+
signal?.addEventListener("abort", () => {
|
|
101
|
+
clearTimeout(t);
|
|
102
|
+
resolve();
|
|
103
|
+
}, { once: true });
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function decodeEntities(s: string): string {
|
|
108
|
+
return s
|
|
109
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
|
110
|
+
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
|
|
111
|
+
.replace(/"/g, '"').replace(/'|'|'/g, "'")
|
|
112
|
+
.replace(/</g, "<").replace(/>/g, ">")
|
|
113
|
+
.replace(/ /g, " ").replace(/&/g, "&");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function rawFetch(
|
|
117
|
+
url: URL,
|
|
118
|
+
opts: FetchOptions,
|
|
119
|
+
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
|
+
});
|
|
135
|
+
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 };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------- jina reader proxy
|
|
144
|
+
|
|
145
|
+
let lastJinaFetch = 0;
|
|
146
|
+
async function jinaFetchText(url: URL, opts: FetchOptions): Promise<FetchResult | null> {
|
|
147
|
+
if (opts.jinaEnabled === false || opts.raw) return null;
|
|
148
|
+
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;
|
|
154
|
+
const res = await fetch(`https://r.jina.ai/${url.href}`, {
|
|
155
|
+
headers: { "User-Agent": TOOL_UA, Accept: "text/plain" },
|
|
156
|
+
signal: combined,
|
|
157
|
+
});
|
|
158
|
+
if (!res.ok) return null;
|
|
159
|
+
let text = await res.text();
|
|
160
|
+
if (text.trim().length < 40) return null;
|
|
161
|
+
// jina sometimes returns the block-page itself — detect and reject
|
|
162
|
+
const hardBlock = /You've been blocked|blocked by network security|log in to your (developer token|Reddit account)/i.test(
|
|
163
|
+
text.slice(0, 3000),
|
|
164
|
+
);
|
|
165
|
+
const softBlock = /Warning: (Target URL returned error|This page maybe requiring CAPTCHA)|Just a moment\.\.\.|Checking your browser/i.test(
|
|
166
|
+
text.slice(0, 2000),
|
|
167
|
+
);
|
|
168
|
+
if (hardBlock) return null;
|
|
169
|
+
if (softBlock) {
|
|
170
|
+
// keep only if there's substantial real content after the warning
|
|
171
|
+
const bodyStart = text.indexOf("Markdown Content:");
|
|
172
|
+
const body = bodyStart >= 0 ? text.slice(bodyStart) : "";
|
|
173
|
+
if (body.replace(/\s+/g, " ").trim().length < 600) return null;
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
text: text.slice(0, opts.maxChars),
|
|
177
|
+
status: 200,
|
|
178
|
+
finalUrl: url.href,
|
|
179
|
+
contentType: "text/markdown (via r.jina.ai)",
|
|
180
|
+
source: "jina",
|
|
181
|
+
truncated: text.length > opts.maxChars,
|
|
182
|
+
fromCache: false,
|
|
183
|
+
};
|
|
184
|
+
} catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// -------------------------------------------------------------- wayback
|
|
190
|
+
|
|
191
|
+
async function waybackFetch(
|
|
192
|
+
url: URL,
|
|
193
|
+
opts: FetchOptions,
|
|
194
|
+
): Promise<FetchResult | null> {
|
|
195
|
+
if (opts.waybackEnabled === false) return null;
|
|
196
|
+
try {
|
|
197
|
+
const api = new URL("https://archive.org/wayback/available");
|
|
198
|
+
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 });
|
|
202
|
+
const data = (await res.json()) as {
|
|
203
|
+
archived_snapshots?: { closest?: { url: string; timestamp: string } };
|
|
204
|
+
};
|
|
205
|
+
const snap = data.archived_snapshots?.closest;
|
|
206
|
+
if (!snap) return null;
|
|
207
|
+
const snapUrl = new URL(snap.url);
|
|
208
|
+
const { res: sres, bodyText, bytes: bytes2 } = await rawFetch(snapUrl, opts);
|
|
209
|
+
if (!sres.ok) return null;
|
|
210
|
+
const { text, truncated } = extract(snapUrl, sres.headers.get("content-type") ?? "", bodyText, opts, bytes2);
|
|
211
|
+
return {
|
|
212
|
+
text,
|
|
213
|
+
status: 200,
|
|
214
|
+
finalUrl: snapUrl.href,
|
|
215
|
+
contentType: sres.headers.get("content-type") ?? "text/html",
|
|
216
|
+
source: "wayback",
|
|
217
|
+
waybackDate: snap.timestamp,
|
|
218
|
+
truncated,
|
|
219
|
+
fromCache: false,
|
|
220
|
+
};
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// -------------------------------------------------------------- extraction
|
|
227
|
+
|
|
228
|
+
const BINARY_TYPES = /^(image\/|video\/|audio\/|application\/(zip|gzip|x-tar|pdf|octet-stream|wasm|sqlite))/;
|
|
229
|
+
function extract(
|
|
230
|
+
url: URL,
|
|
231
|
+
contentType: string,
|
|
232
|
+
body: string,
|
|
233
|
+
opts: FetchOptions,
|
|
234
|
+
bytes?: Buffer,
|
|
235
|
+
): { text: string; truncated: boolean } {
|
|
236
|
+
const ct = contentType.split(";")[0].trim().toLowerCase();
|
|
237
|
+
|
|
238
|
+
// PDF → text extraction (poppler if installed, else internal parser)
|
|
239
|
+
if (bytes && (ct === "application/pdf" || /\.pdf($|\?)/i.test(url.pathname))) {
|
|
240
|
+
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 {
|
|
247
|
+
return {
|
|
248
|
+
text: `[PDF detected (${bytes.length} bytes) but text extraction failed — likely scanned/image-only or encrypted]`,
|
|
249
|
+
truncated: false,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Binary content: metadata only
|
|
255
|
+
if (BINARY_TYPES.test(ct)) {
|
|
256
|
+
return {
|
|
257
|
+
text: `[binary content: ${ct} — ${body.length} bytes received, not text-extractable]`,
|
|
258
|
+
truncated: false,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
if (opts.raw) {
|
|
262
|
+
return { text: body.slice(0, opts.maxChars), truncated: body.length > opts.maxChars };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// JSON → pretty print
|
|
266
|
+
if (ct === "application/json" || ct.endsWith("+json")) {
|
|
267
|
+
try {
|
|
268
|
+
const pretty = JSON.stringify(JSON.parse(body), null, 2);
|
|
269
|
+
return { text: pretty.slice(0, opts.maxChars), truncated: pretty.length > opts.maxChars };
|
|
270
|
+
} catch {
|
|
271
|
+
/* fall through to text */
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (ct.startsWith("text/") && !ct.includes("html")) {
|
|
275
|
+
return { text: body.slice(0, opts.maxChars), truncated: body.length > opts.maxChars };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// HTML → readable text
|
|
279
|
+
const format = opts.format ?? "markdown";
|
|
280
|
+
if (format === "markdown") {
|
|
281
|
+
try {
|
|
282
|
+
const md = htmlToMarkdown(body, url.href, opts.maxChars);
|
|
283
|
+
if (md.text) return md; // junk check inside; fall through on failure
|
|
284
|
+
} catch {
|
|
285
|
+
/* fall through to flattener */
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const { text, truncated } = htmlToText(body, opts.maxChars);
|
|
289
|
+
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
|
+
return {
|
|
291
|
+
text: text + "\n\n[page appears to be a client-rendered SPA — little static text available]",
|
|
292
|
+
truncated,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
if (text.length < 200 && body.length > 20000) {
|
|
296
|
+
return {
|
|
297
|
+
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
|
+
truncated,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
return { text, truncated };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ------------------------------------------------------------------- main
|
|
305
|
+
|
|
306
|
+
/**
|
|
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.
|
|
310
|
+
*/
|
|
311
|
+
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 };
|
|
318
|
+
}
|
|
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 };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function smartFetchRaw(url: string, opts: FetchOptions): Promise<FetchResult> {
|
|
328
|
+
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);
|
|
331
|
+
if (cached) return { ...cached, fromCache: true };
|
|
332
|
+
|
|
333
|
+
// site adapters: known URL shapes route to their clean API (github/so/hn/reddit/wikipedia)
|
|
334
|
+
if (!opts.raw) {
|
|
335
|
+
const { trySiteAdapter } = await import("./adapters.ts");
|
|
336
|
+
const ad = await trySiteAdapter(safeUrl.href, opts.signal).catch(() => null);
|
|
337
|
+
if (ad) {
|
|
338
|
+
const out: FetchResult = {
|
|
339
|
+
text: ad.text.slice(0, opts.maxChars),
|
|
340
|
+
status: 200,
|
|
341
|
+
finalUrl: safeUrl.href,
|
|
342
|
+
contentType: "text/markdown",
|
|
343
|
+
source: ad.source as any,
|
|
344
|
+
truncated: ad.text.length > opts.maxChars,
|
|
345
|
+
fromCache: false,
|
|
346
|
+
};
|
|
347
|
+
FETCH_CACHE.set(cacheKey, out);
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// retry with exponential backoff on transient failures
|
|
353
|
+
let lastErr: unknown;
|
|
354
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
355
|
+
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;
|
|
362
|
+
}
|
|
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 };
|
|
366
|
+
}
|
|
367
|
+
throw new Error(`HTTP ${res.status}${res.status === 403 ? " (bot protection?)" : ""}`);
|
|
368
|
+
}
|
|
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}`);
|
|
375
|
+
}
|
|
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
|
+
}
|
|
385
|
+
}
|
|
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;
|
|
397
|
+
} catch (err) {
|
|
398
|
+
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));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
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
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
410
|
+
}
|
|
411
|
+
|
|
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;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
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
|
+
}
|
package/lib/pdf.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal zero-dependency PDF text extraction.
|
|
3
|
+
* Handles: FlateDecode streams (zlib), raw streams, literal/hex strings,
|
|
4
|
+
* Tj/TJ/'/" operators, UTF-16BE strings, octal escapes.
|
|
5
|
+
* Limitations: no ToUnicode CMap parsing (subset fonts may extract imperfectly),
|
|
6
|
+
* no encrypted-PDF decryption (fails gracefully).
|
|
7
|
+
*/
|
|
8
|
+
import { inflateSync, inflateRawSync } from "node:zlib";
|
|
9
|
+
|
|
10
|
+
function findStreams(data: Buffer): Array<{ dict: string; start: number; end: number }> {
|
|
11
|
+
const streams: Array<{ dict: string; start: number; end: number }> = [];
|
|
12
|
+
const latin = data.toString("latin1");
|
|
13
|
+
let pos = 0;
|
|
14
|
+
while (true) {
|
|
15
|
+
const s = latin.indexOf("stream", pos);
|
|
16
|
+
if (s === -1) break;
|
|
17
|
+
// must be the keyword (preceded by dict end >>), not inside a word
|
|
18
|
+
if ((s === 0 || !/[a-zA-Z]/.test(latin[s - 1])) && !latin.startsWith("endstream", s)) {
|
|
19
|
+
const dictStart = latin.lastIndexOf("<<", s);
|
|
20
|
+
const dict = dictStart >= 0 ? latin.slice(Math.max(dictStart, s - 600), s) : "";
|
|
21
|
+
let body = s + 6;
|
|
22
|
+
if (latin[body] === "\r") body++;
|
|
23
|
+
if (latin[body] === "\n") body++;
|
|
24
|
+
const e = latin.indexOf("endstream", body);
|
|
25
|
+
if (e === -1) break;
|
|
26
|
+
streams.push({ dict, start: body, end: e });
|
|
27
|
+
pos = e + 9;
|
|
28
|
+
} else {
|
|
29
|
+
pos = s + 6;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return streams;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function inflateStream(data: Buffer, start: number, end: number): Buffer | null {
|
|
36
|
+
const slice = data.subarray(start, end);
|
|
37
|
+
for (const fn of [inflateSync, inflateRawSync]) {
|
|
38
|
+
try {
|
|
39
|
+
return fn(slice);
|
|
40
|
+
} catch {
|
|
41
|
+
/* try next */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function decodePdfString(raw: string): string {
|
|
48
|
+
// UTF-16BE BOM
|
|
49
|
+
if (raw.startsWith("\xFE\xFF")) {
|
|
50
|
+
let out = "";
|
|
51
|
+
for (let i = 2; i + 1 < raw.length; i += 2) {
|
|
52
|
+
out += String.fromCharCode((raw.charCodeAt(i) << 8) | raw.charCodeAt(i + 1));
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
return raw;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function unescapePdfString(s: string): string {
|
|
60
|
+
let out = "";
|
|
61
|
+
for (let i = 0; i < s.length; i++) {
|
|
62
|
+
const c = s[i];
|
|
63
|
+
if (c !== "\\") {
|
|
64
|
+
out += c;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const n = s[++i];
|
|
68
|
+
if (n === undefined) break;
|
|
69
|
+
if (n === "n") out += "\n";
|
|
70
|
+
else if (n === "r") out += "\r";
|
|
71
|
+
else if (n === "t") out += "\t";
|
|
72
|
+
else if (n === "b") out += "\b";
|
|
73
|
+
else if (n === "f") out += "\f";
|
|
74
|
+
else if (n >= "0" && n <= "7") {
|
|
75
|
+
// up to 3 octal digits
|
|
76
|
+
let oct = n;
|
|
77
|
+
while (oct.length < 3 && s[i + 1] >= "0" && s[i + 1] <= "7") oct += s[++i];
|
|
78
|
+
out += String.fromCharCode(parseInt(oct, 8));
|
|
79
|
+
} else out += n; // \( \\ \) and any other escaped char
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Extract readable text from decoded PDF content-stream operators. */
|
|
85
|
+
function extractFromContent(content: string): string[] {
|
|
86
|
+
const lines: string[] = [];
|
|
87
|
+
let current = "";
|
|
88
|
+
// token scan
|
|
89
|
+
const re =
|
|
90
|
+
/\((?:\\.|[^\\()])*\)\s*Tj|\<[0-9A-Fa-f\s]+\>\s*Tj|\[(?:[^\][]|\\.)*\]\s*TJ|T\*|Td|TD|Tm|ET|'(?:\s*(?:\\.|[^\\()])*\))?|"(?:\s*(?:\\.|[^\\()])*\s*(?:\\.|[^\\()])*\s*(?:\\.|[^\\()])*)?|BT/g;
|
|
91
|
+
let m: RegExpExecArray | null;
|
|
92
|
+
const flush = () => {
|
|
93
|
+
const t = current.trim();
|
|
94
|
+
if (t) lines.push(t);
|
|
95
|
+
current = "";
|
|
96
|
+
};
|
|
97
|
+
while ((m = re.exec(content))) {
|
|
98
|
+
const tok = m[0];
|
|
99
|
+
if (tok === "T*" || tok === "Td" || tok === "TD" || tok === "Tm" || tok === "ET" || tok === "BT") {
|
|
100
|
+
flush();
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
let text = "";
|
|
104
|
+
if (tok.startsWith("(")) {
|
|
105
|
+
// find the raw string inside ( ... ) including escapes up to Tj
|
|
106
|
+
const inner = tok.slice(1, tok.lastIndexOf(")"));
|
|
107
|
+
text = decodePdfString(unescapePdfString(inner));
|
|
108
|
+
} else if (tok.startsWith("<")) {
|
|
109
|
+
const hex = tok.slice(1, tok.lastIndexOf(">")).replace(/[^0-9A-Fa-f]/g, "");
|
|
110
|
+
let s = "";
|
|
111
|
+
for (let i = 0; i + 1 < hex.length; i += 2) s += String.fromCharCode(parseInt(hex.slice(i, i + 2), 16));
|
|
112
|
+
text = decodePdfString(s);
|
|
113
|
+
} else if (tok.startsWith("[")) {
|
|
114
|
+
// TJ array: concatenate literal strings; large negative offsets = spacing
|
|
115
|
+
const inner = tok.slice(1, tok.lastIndexOf("]"));
|
|
116
|
+
for (const sm of inner.matchAll(/\((?:\\.|[^\\()])*\)/g)) {
|
|
117
|
+
const raw = sm[0].slice(1, -1);
|
|
118
|
+
text += decodePdfString(unescapePdfString(raw));
|
|
119
|
+
}
|
|
120
|
+
} else if (tok.startsWith("'") || tok.startsWith('"')) {
|
|
121
|
+
flush();
|
|
122
|
+
// strings in '/" handled by following Tj tokens; treat as newline
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (text) current += text;
|
|
126
|
+
}
|
|
127
|
+
flush();
|
|
128
|
+
return lines;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Extract PDF text: poppler's pdftotext if installed (best quality), else internal extractor. */
|
|
132
|
+
export async function extractPdf(bytes: Buffer): Promise<string> {
|
|
133
|
+
if (bytes.subarray(0, 5).toString("latin1") !== "%PDF-") throw new Error("not a PDF");
|
|
134
|
+
const viaPoppler = await extractPdfViaPoppler(bytes);
|
|
135
|
+
if (viaPoppler) return viaPoppler;
|
|
136
|
+
|
|
137
|
+
const streams = findStreams(bytes);
|
|
138
|
+
const chunks: string[] = [];
|
|
139
|
+
for (const st of streams) {
|
|
140
|
+
if (/\/Filter\s*\[^\]]*FlateDecode/i.test(st.dict) || /\/Filter\s*\/Fl\b/i.test(st.dict)) {
|
|
141
|
+
const inflated = inflateStream(bytes, st.start, st.end);
|
|
142
|
+
if (inflated) chunks.push(inflated.toString("latin1"));
|
|
143
|
+
} else if (!/\/Filter/.test(st.dict)) {
|
|
144
|
+
chunks.push(bytes.subarray(st.start, st.end).toString("latin1"));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (chunks.length === 0) throw new Error("no readable PDF content streams (encrypted or image-only PDF?)");
|
|
148
|
+
const all = chunks.join("\n");
|
|
149
|
+
const lines = extractFromContent(all);
|
|
150
|
+
if (lines.length === 0) throw new Error("PDF parsed but no text found (scanned/image PDF?)");
|
|
151
|
+
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** pdftotext with stdin — used when poppler is installed (best quality). */
|
|
155
|
+
export async function extractPdfViaPoppler(bytes: Buffer): Promise<string | null> {
|
|
156
|
+
try {
|
|
157
|
+
const { spawn } = await import("node:child_process");
|
|
158
|
+
return await new Promise<string>((resolve, reject) => {
|
|
159
|
+
const child = spawn("pdftotext", ["-", "-"], { stdio: ["pipe", "pipe", "ignore"] });
|
|
160
|
+
let out = "";
|
|
161
|
+
const timer = setTimeout(() => {
|
|
162
|
+
child.kill();
|
|
163
|
+
reject(new Error("pdftotext timeout"));
|
|
164
|
+
}, 20_000);
|
|
165
|
+
child.stdout.on("data", (d) => (out += d.toString()));
|
|
166
|
+
child.on("close", (code) => {
|
|
167
|
+
clearTimeout(timer);
|
|
168
|
+
if (code === 0 && out.trim().length > 0) resolve(out);
|
|
169
|
+
else reject(new Error(`pdftotext exited ${code}`));
|
|
170
|
+
});
|
|
171
|
+
child.on("error", (err) => {
|
|
172
|
+
clearTimeout(timer);
|
|
173
|
+
reject(err);
|
|
174
|
+
});
|
|
175
|
+
child.stdin.write(bytes);
|
|
176
|
+
child.stdin.end();
|
|
177
|
+
});
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|