pi-unsloth-webtools 0.1.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 +661 -0
- package/README.md +101 -0
- package/ROADMAP.md +77 -0
- package/engines.ts +855 -0
- package/entities.ts +2399 -0
- package/html-to-md.ts +1085 -0
- package/index.ts +86 -0
- package/package.json +64 -0
- package/pdf.ts +717 -0
- package/web-access.ts +375 -0
- package/web-fetch.ts +689 -0
- package/web-search.ts +112 -0
package/web-fetch.ts
ADDED
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import https from "node:https";
|
|
4
|
+
import type { IncomingMessage } from "node:http";
|
|
5
|
+
import {
|
|
6
|
+
checkUrlAccess,
|
|
7
|
+
githubRepoReadmeApiUrl,
|
|
8
|
+
isPublicIp,
|
|
9
|
+
normalizeUrlScheme,
|
|
10
|
+
type WebsitePolicy,
|
|
11
|
+
} from "./web-access.ts";
|
|
12
|
+
import { htmlToMarkdown } from "./html-to-md.ts";
|
|
13
|
+
import { extractPdfText, PdfParseError } from "./pdf.ts";
|
|
14
|
+
|
|
15
|
+
const MIN_PAGE_CHARS = 2000;
|
|
16
|
+
const MAX_FETCH_BYTES = 512 * 1024;
|
|
17
|
+
const MAX_PDF_FETCH_BYTES = 10 * 1024 * 1024;
|
|
18
|
+
const MAX_REDIRECTS = 5;
|
|
19
|
+
|
|
20
|
+
const USER_AGENTS = [
|
|
21
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
22
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
23
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
24
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0",
|
|
25
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0",
|
|
26
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const UTF32_LE_BOM = Buffer.from([0xff, 0xfe, 0x00, 0x00]);
|
|
30
|
+
const UTF32_BE_BOM = Buffer.from([0x00, 0x00, 0xfe, 0xff]);
|
|
31
|
+
const UTF16_LE_BOM = Buffer.from([0xff, 0xfe]);
|
|
32
|
+
const UTF16_BE_BOM = Buffer.from([0xfe, 0xff]);
|
|
33
|
+
const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]);
|
|
34
|
+
|
|
35
|
+
const BINARY_CHAR_RE = /[\x00-\x08\x0b\x0c\x0e-\x1a\x1c-\x1f\x7f-\x9f\ufffd]/g;
|
|
36
|
+
const MIN_BINARY_CHARS = 16;
|
|
37
|
+
const BINARY_CHAR_DIVISOR = 8;
|
|
38
|
+
const PDF_MAGIC = "%PDF-";
|
|
39
|
+
const BINARY_MAGICS = [
|
|
40
|
+
Buffer.from("%PDF-"),
|
|
41
|
+
Buffer.from("PK\x03\x04"),
|
|
42
|
+
Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]),
|
|
43
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
44
|
+
Buffer.from([0xff, 0xd8, 0xff]),
|
|
45
|
+
Buffer.from("GIF87a"),
|
|
46
|
+
Buffer.from("GIF89a"),
|
|
47
|
+
Buffer.from([0x1f, 0x8b]),
|
|
48
|
+
Buffer.from("BZh"),
|
|
49
|
+
Buffer.from([0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00]),
|
|
50
|
+
Buffer.from([0x28, 0xb5, 0x2f, 0xfd]),
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const BINARY_APPLICATION_SUBTYPES = new Set([
|
|
54
|
+
"epub+zip",
|
|
55
|
+
"gzip",
|
|
56
|
+
"java-archive",
|
|
57
|
+
"pdf",
|
|
58
|
+
"vnd.apple.installer+xml",
|
|
59
|
+
"wasm",
|
|
60
|
+
"x-7z-compressed",
|
|
61
|
+
"x-bzip2",
|
|
62
|
+
"x-gzip",
|
|
63
|
+
"x-rar-compressed",
|
|
64
|
+
"x-tar",
|
|
65
|
+
"x-xz",
|
|
66
|
+
"zip",
|
|
67
|
+
"zstd",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
const HTML_LEADING_TAGS = [
|
|
71
|
+
"html",
|
|
72
|
+
"head",
|
|
73
|
+
"body",
|
|
74
|
+
"title",
|
|
75
|
+
"meta",
|
|
76
|
+
"link",
|
|
77
|
+
"script",
|
|
78
|
+
"style",
|
|
79
|
+
"article",
|
|
80
|
+
"section",
|
|
81
|
+
"main",
|
|
82
|
+
"header",
|
|
83
|
+
"footer",
|
|
84
|
+
"nav",
|
|
85
|
+
"aside",
|
|
86
|
+
"figure",
|
|
87
|
+
"form",
|
|
88
|
+
"ul",
|
|
89
|
+
"ol",
|
|
90
|
+
"dl",
|
|
91
|
+
"pre",
|
|
92
|
+
"blockquote",
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
const HTML_LEADING_RE = new RegExp(
|
|
96
|
+
`^<(?:!doctype\\s+html|/?(?:${HTML_LEADING_TAGS.join("|")})\\b)`,
|
|
97
|
+
);
|
|
98
|
+
const HTML_DOCUMENT_RE = /^<(?:!doctype\s+html\b|\/?(?:html|head|body)\b)/;
|
|
99
|
+
|
|
100
|
+
const MIN_SINGLE_BYTE_ASCII_RATIO = 3 / 4;
|
|
101
|
+
const ASCII_TEXT_BYTES = new Set<number>([
|
|
102
|
+
...Array.from({ length: 0x7f - 0x20 }, (_, i) => i + 0x20),
|
|
103
|
+
0x09,
|
|
104
|
+
0x0a,
|
|
105
|
+
0x0d,
|
|
106
|
+
0x1b,
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
const CP1252_HIGH: Record<number, number> = {
|
|
110
|
+
0x80: 0x20ac, 0x82: 0x201a, 0x83: 0x0192, 0x84: 0x201e, 0x85: 0x2026,
|
|
111
|
+
0x86: 0x2020, 0x87: 0x2021, 0x88: 0x02c6, 0x89: 0x2030, 0x8a: 0x0160,
|
|
112
|
+
0x8b: 0x2039, 0x8c: 0x0152, 0x8e: 0x017d, 0x91: 0x2018, 0x92: 0x2019,
|
|
113
|
+
0x93: 0x201c, 0x94: 0x201d, 0x95: 0x2022, 0x96: 0x2013, 0x97: 0x2014,
|
|
114
|
+
0x98: 0x02dc, 0x99: 0x2122, 0x9a: 0x0161, 0x9b: 0x203a, 0x9c: 0x0153,
|
|
115
|
+
0x9e: 0x017e, 0x9f: 0x0178,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export interface FetchPageOptions {
|
|
119
|
+
timeoutMs?: number;
|
|
120
|
+
deadlineMs?: number;
|
|
121
|
+
nowMs?: () => number;
|
|
122
|
+
signal?: AbortSignal;
|
|
123
|
+
websitePolicy?: WebsitePolicy | null;
|
|
124
|
+
maxChars?: number;
|
|
125
|
+
maxBytes?: number;
|
|
126
|
+
maxPdfBytes?: number;
|
|
127
|
+
seams?: FetchSeams;
|
|
128
|
+
rawFetch?: (url: string, options: RawFetchOptions) => Promise<RawFetchResult>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface HopResponse {
|
|
132
|
+
status: number;
|
|
133
|
+
headers: Record<string, string | string[] | undefined>;
|
|
134
|
+
body: Buffer;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface ResolvedHost {
|
|
138
|
+
ok: boolean;
|
|
139
|
+
reason: string;
|
|
140
|
+
ip: string;
|
|
141
|
+
family: number;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface FetchSeams {
|
|
145
|
+
resolve?: (hostname: string, signal?: AbortSignal) => Promise<ResolvedHost>;
|
|
146
|
+
request?: (opts: HopOptions) => Promise<HopResponse>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface HopOptions {
|
|
150
|
+
url: URL;
|
|
151
|
+
pinnedIp: string;
|
|
152
|
+
family: number;
|
|
153
|
+
headers: Record<string, string>;
|
|
154
|
+
maxBytes: number;
|
|
155
|
+
maxPdfBytes: number;
|
|
156
|
+
inactivityMs: number;
|
|
157
|
+
signal?: AbortSignal;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface RawFetchOptions {
|
|
161
|
+
timeoutMs?: number;
|
|
162
|
+
deadlineMs?: number;
|
|
163
|
+
nowMs?: () => number;
|
|
164
|
+
signal?: AbortSignal;
|
|
165
|
+
extraHeaders?: Record<string, string>;
|
|
166
|
+
websitePolicy?: WebsitePolicy | null;
|
|
167
|
+
maxBytes?: number;
|
|
168
|
+
maxPdfBytes?: number;
|
|
169
|
+
seams?: FetchSeams;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface RawFetchResult {
|
|
173
|
+
error: string | null;
|
|
174
|
+
body: string;
|
|
175
|
+
contentType: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function looksLikeHtml(body: string): boolean {
|
|
179
|
+
const probe = body.replace(/^[ \t\n\r\f\v]+/, "").slice(0, 256).toLowerCase();
|
|
180
|
+
return HTML_LEADING_RE.test(probe);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function looksLikeHtmlDocument(body: string): boolean {
|
|
184
|
+
const probe = body.replace(/^[ \t\n\r\f\v]+/, "").slice(0, 256).toLowerCase();
|
|
185
|
+
return HTML_DOCUMENT_RE.test(probe);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function isTextCandidateContentType(contentType: string | null): boolean {
|
|
189
|
+
const match = /^[\w.+-]+\/[\w.+-]+/.exec(contentType ?? "");
|
|
190
|
+
if (!match) return true;
|
|
191
|
+
const ct = match[0].toLowerCase();
|
|
192
|
+
if (ct.startsWith("text/")) return true;
|
|
193
|
+
if (ct.startsWith("application/")) {
|
|
194
|
+
const subtype = ct.slice("application/".length);
|
|
195
|
+
return !BINARY_APPLICATION_SUBTYPES.has(subtype);
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function looksBinary(text: string): boolean {
|
|
201
|
+
return (text.match(BINARY_CHAR_RE) ?? []).length > Math.max(MIN_BINARY_CHARS, Math.floor(text.length / BINARY_CHAR_DIVISOR));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function magicHead(data: Buffer): Buffer {
|
|
205
|
+
let head = data.subarray(0, 1024);
|
|
206
|
+
let start = 0;
|
|
207
|
+
while (start < head.length && [0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x20].includes(head[start])) start++;
|
|
208
|
+
for (const bom of [UTF32_LE_BOM, UTF32_BE_BOM, UTF16_LE_BOM, UTF16_BE_BOM, UTF8_BOM]) {
|
|
209
|
+
if (head.subarray(start).length >= bom.length && head.subarray(start, start + bom.length).equals(bom)) {
|
|
210
|
+
start += bom.length;
|
|
211
|
+
while (start < head.length && [0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x20].includes(head[start])) start++;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return head.subarray(start);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function hasPdfMagic(data: Buffer): boolean {
|
|
219
|
+
const head = magicHead(data);
|
|
220
|
+
const magic = Buffer.from(PDF_MAGIC);
|
|
221
|
+
return head.length >= magic.length && head.subarray(0, magic.length).equals(magic);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function hasBinaryMagic(data: Buffer): boolean {
|
|
225
|
+
const head = magicHead(data);
|
|
226
|
+
return BINARY_MAGICS.some((magic) =>
|
|
227
|
+
head.length >= magic.length && head.subarray(0, magic.length).equals(magic),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function hasSingleByteTextEvidence(data: Buffer): boolean {
|
|
232
|
+
if (!data.length) return true;
|
|
233
|
+
let ascii = 0;
|
|
234
|
+
for (const byte of data) {
|
|
235
|
+
if (ASCII_TEXT_BYTES.has(byte)) ascii++;
|
|
236
|
+
}
|
|
237
|
+
return ascii / data.length >= MIN_SINGLE_BYTE_ASCII_RATIO;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function normalizeCharset(name: string): string | null {
|
|
241
|
+
const n = name.trim().replace(/["']/g, "").toLowerCase();
|
|
242
|
+
switch (n) {
|
|
243
|
+
case "utf-8":
|
|
244
|
+
case "utf8":
|
|
245
|
+
case "utf-8-sig":
|
|
246
|
+
return "utf-8";
|
|
247
|
+
case "iso-8859-1":
|
|
248
|
+
case "iso8859-1":
|
|
249
|
+
case "latin1":
|
|
250
|
+
case "latin-1":
|
|
251
|
+
case "us-ascii":
|
|
252
|
+
case "ascii":
|
|
253
|
+
return "iso8859-1";
|
|
254
|
+
case "windows-1252":
|
|
255
|
+
case "cp1252":
|
|
256
|
+
case "x-cp1252":
|
|
257
|
+
return "cp1252";
|
|
258
|
+
case "utf-16":
|
|
259
|
+
case "utf-16le":
|
|
260
|
+
case "utf16le":
|
|
261
|
+
case "ucs-2":
|
|
262
|
+
case "ucs2":
|
|
263
|
+
return "utf-16le";
|
|
264
|
+
case "utf-16be":
|
|
265
|
+
case "utf16be":
|
|
266
|
+
return "utf-16be";
|
|
267
|
+
case "utf-32":
|
|
268
|
+
case "utf-32le":
|
|
269
|
+
return "utf-32le";
|
|
270
|
+
case "utf-32be":
|
|
271
|
+
return "utf-32be";
|
|
272
|
+
default:
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function decodeUtf32(bytes: Buffer, littleEndian: boolean): string {
|
|
278
|
+
let out = "";
|
|
279
|
+
for (let i = 0; i + 3 < bytes.length; i += 4) {
|
|
280
|
+
const v = littleEndian ? bytes.readUInt32LE(i) : bytes.readUInt32BE(i);
|
|
281
|
+
if (v === 0 || v > 0x10ffff || (v >= 0xd800 && v <= 0xdfff)) {
|
|
282
|
+
out += "\ufffd";
|
|
283
|
+
} else {
|
|
284
|
+
out += String.fromCodePoint(v);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function decodeUtf16Be(bytes: Buffer): string {
|
|
291
|
+
let out = "";
|
|
292
|
+
for (let i = 0; i + 1 < bytes.length; i += 2) {
|
|
293
|
+
out += String.fromCharCode(bytes.readUInt16BE(i));
|
|
294
|
+
}
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function decodeSingleByte(bytes: Buffer, cp1252: boolean): string {
|
|
299
|
+
let out = "";
|
|
300
|
+
for (const byte of bytes) {
|
|
301
|
+
if (byte < 0x80) {
|
|
302
|
+
out += String.fromCharCode(byte);
|
|
303
|
+
} else if (cp1252 && byte in CP1252_HIGH) {
|
|
304
|
+
out += String.fromCodePoint(CP1252_HIGH[byte]);
|
|
305
|
+
} else {
|
|
306
|
+
out += String.fromCharCode(byte);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function decodeWithCodec(bytes: Buffer, codec: string | null): string {
|
|
313
|
+
switch (codec) {
|
|
314
|
+
case "utf-8":
|
|
315
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
|
316
|
+
case "utf-16le":
|
|
317
|
+
return new TextDecoder("utf-16le", { fatal: false }).decode(bytes);
|
|
318
|
+
case "utf-16be":
|
|
319
|
+
try {
|
|
320
|
+
return new TextDecoder("utf-16be", { fatal: false }).decode(bytes);
|
|
321
|
+
} catch {
|
|
322
|
+
return decodeUtf16Be(bytes);
|
|
323
|
+
}
|
|
324
|
+
case "utf-32le":
|
|
325
|
+
return decodeUtf32(bytes, true);
|
|
326
|
+
case "utf-32be":
|
|
327
|
+
return decodeUtf32(bytes, false);
|
|
328
|
+
case "cp1252":
|
|
329
|
+
return decodeSingleByte(bytes, true);
|
|
330
|
+
case "iso8859-1":
|
|
331
|
+
default:
|
|
332
|
+
return decodeSingleByte(bytes, false);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
async function resolveAndValidate(hostname: string, signal?: AbortSignal): Promise<ResolvedHost> {
|
|
338
|
+
let addresses: { address: string; family: number }[];
|
|
339
|
+
try {
|
|
340
|
+
addresses = await dnsLookup(hostname, { all: true, verbatim: true });
|
|
341
|
+
} catch (err) {
|
|
342
|
+
return { ok: false, reason: `Failed to resolve host: ${err}`, ip: "", family: 0 };
|
|
343
|
+
}
|
|
344
|
+
if (!addresses.length) {
|
|
345
|
+
return { ok: false, reason: `Failed to resolve host: no addresses for '${hostname}'`, ip: "", family: 0 };
|
|
346
|
+
}
|
|
347
|
+
for (const entry of addresses) {
|
|
348
|
+
if (!isPublicIp(entry.address)) {
|
|
349
|
+
return { ok: false, reason: `Blocked: refusing to fetch non-public address ${entry.address}.`, ip: "", family: 0 };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const first = addresses[0];
|
|
353
|
+
return { ok: true, reason: "", ip: first.address, family: first.family };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
function fetchBudgetExceeded(
|
|
358
|
+
deadline: number | null,
|
|
359
|
+
signal: AbortSignal | undefined,
|
|
360
|
+
now: () => number = Date.now,
|
|
361
|
+
): string | null {
|
|
362
|
+
if (signal?.aborted) return "Failed to fetch URL: cancelled.";
|
|
363
|
+
if (deadline !== null && now() >= deadline) return "Failed to fetch URL: timed out.";
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
function requestHop(opts: HopOptions): Promise<HopResponse> {
|
|
369
|
+
return new Promise((resolve, reject) => {
|
|
370
|
+
const url = opts.url;
|
|
371
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
372
|
+
const options: https.RequestOptions = {
|
|
373
|
+
method: "GET",
|
|
374
|
+
host: url.hostname,
|
|
375
|
+
port: url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80,
|
|
376
|
+
path: url.pathname + url.search,
|
|
377
|
+
headers: opts.headers,
|
|
378
|
+
timeout: opts.inactivityMs,
|
|
379
|
+
servername: url.protocol === "https:" ? url.hostname : undefined,
|
|
380
|
+
lookup: (_host, _opts, callback) =>
|
|
381
|
+
callback(null, [{ address: opts.pinnedIp, family: opts.family }]),
|
|
382
|
+
};
|
|
383
|
+
const request = transport.request(options, (res: IncomingMessage) => {
|
|
384
|
+
const chunks: Buffer[] = [];
|
|
385
|
+
let total = 0;
|
|
386
|
+
const declaredPdf = String(res.headers["content-type"] ?? "").toLowerCase().includes("pdf");
|
|
387
|
+
let limit = declaredPdf ? opts.maxPdfBytes : opts.maxBytes;
|
|
388
|
+
let extendedForPdf = false;
|
|
389
|
+
let done = false;
|
|
390
|
+
const finish = (err: string | null, body: Buffer) => {
|
|
391
|
+
if (done) return;
|
|
392
|
+
done = true;
|
|
393
|
+
if (err) reject(new Error(err));
|
|
394
|
+
else
|
|
395
|
+
resolve({
|
|
396
|
+
status: res.statusCode ?? 0,
|
|
397
|
+
headers: res.headers as Record<string, string | string[] | undefined>,
|
|
398
|
+
body,
|
|
399
|
+
});
|
|
400
|
+
};
|
|
401
|
+
res.on("data", (chunk: Buffer) => {
|
|
402
|
+
if (done) return;
|
|
403
|
+
if (!declaredPdf && !extendedForPdf && total + chunk.length > opts.maxBytes) {
|
|
404
|
+
if (hasPdfMagic(Buffer.concat(chunks))) {
|
|
405
|
+
limit = opts.maxPdfBytes;
|
|
406
|
+
extendedForPdf = true;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const space = limit - total;
|
|
410
|
+
if (space <= 0) {
|
|
411
|
+
res.destroy();
|
|
412
|
+
finish(null, Buffer.concat(chunks));
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const take = chunk.subarray(0, Math.min(chunk.length, space));
|
|
416
|
+
chunks.push(take);
|
|
417
|
+
total += take.length;
|
|
418
|
+
if (total >= limit) {
|
|
419
|
+
res.destroy();
|
|
420
|
+
finish(null, Buffer.concat(chunks));
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
res.on("end", () => finish(null, Buffer.concat(chunks)));
|
|
424
|
+
res.on("error", (err) => finish(err.message, Buffer.concat(chunks)));
|
|
425
|
+
});
|
|
426
|
+
request.on("timeout", () => request.destroy(new Error("timed out")));
|
|
427
|
+
request.on("error", (err) => reject(err));
|
|
428
|
+
const onAbort = () => request.destroy(new Error("cancelled"));
|
|
429
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
430
|
+
request.end();
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
export async function fetchUrlRaw(
|
|
437
|
+
url: string,
|
|
438
|
+
options: RawFetchOptions = {},
|
|
439
|
+
): Promise<RawFetchResult> {
|
|
440
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
441
|
+
const now = options.nowMs ?? Date.now;
|
|
442
|
+
const deadline = options.deadlineMs ?? now() + timeoutMs;
|
|
443
|
+
const signal = options.signal;
|
|
444
|
+
const policy = options.websitePolicy ?? null;
|
|
445
|
+
const maxBytes = options.maxBytes ?? MAX_FETCH_BYTES;
|
|
446
|
+
const maxPdfBytes = options.maxPdfBytes ?? MAX_PDF_FETCH_BYTES;
|
|
447
|
+
const seams = options.seams ?? {};
|
|
448
|
+
const resolveHost = seams.resolve ?? resolveAndValidate;
|
|
449
|
+
const performRequest = seams.request ?? requestHop;
|
|
450
|
+
|
|
451
|
+
url = normalizeUrlScheme(url);
|
|
452
|
+
const [allowed, reason, hostname] = checkUrlAccess(url, policy);
|
|
453
|
+
if (!allowed) return { error: reason, body: "", contentType: "" };
|
|
454
|
+
|
|
455
|
+
let budgetError = fetchBudgetExceeded(deadline, signal, now);
|
|
456
|
+
if (budgetError !== null) return { error: budgetError, body: "", contentType: "" };
|
|
457
|
+
let resolved = await resolveHost(hostname, signal);
|
|
458
|
+
if (!resolved.ok) return { error: resolved.reason, body: "", contentType: "" };
|
|
459
|
+
|
|
460
|
+
let currentUrl = url;
|
|
461
|
+
let pinnedIp = resolved.ip;
|
|
462
|
+
let pinnedFamily = resolved.family;
|
|
463
|
+
const userAgent = USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
|
|
464
|
+
|
|
465
|
+
for (let hop = 0; hop < MAX_REDIRECTS; hop++) {
|
|
466
|
+
budgetError = fetchBudgetExceeded(deadline, signal, now);
|
|
467
|
+
if (budgetError !== null) return { error: budgetError, body: "", contentType: "" };
|
|
468
|
+
const parsed = new URL(currentUrl);
|
|
469
|
+
const hostHeader = parsed.hostname + (parsed.port ? `:${parsed.port}` : "");
|
|
470
|
+
const headers: Record<string, string> = {
|
|
471
|
+
"User-Agent": userAgent,
|
|
472
|
+
Host: hostHeader,
|
|
473
|
+
Accept: "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.5",
|
|
474
|
+
};
|
|
475
|
+
if (options.extraHeaders) Object.assign(headers, options.extraHeaders);
|
|
476
|
+
const inactivity = Math.max(1, deadline - now());
|
|
477
|
+
let response: HopResponse;
|
|
478
|
+
try {
|
|
479
|
+
response = await performRequest({
|
|
480
|
+
url: parsed,
|
|
481
|
+
pinnedIp,
|
|
482
|
+
family: pinnedFamily,
|
|
483
|
+
headers,
|
|
484
|
+
maxBytes,
|
|
485
|
+
maxPdfBytes,
|
|
486
|
+
inactivityMs: inactivity,
|
|
487
|
+
signal,
|
|
488
|
+
});
|
|
489
|
+
} catch (err) {
|
|
490
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
491
|
+
if (message === "cancelled")
|
|
492
|
+
return { error: "Failed to fetch URL: cancelled.", body: "", contentType: "" };
|
|
493
|
+
if (message === "timed out")
|
|
494
|
+
return { error: "Failed to fetch URL: timed out.", body: "", contentType: "" };
|
|
495
|
+
return { error: `Failed to fetch URL: ${message}`, body: "", contentType: "" };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (response.status >= 300 && response.status < 400) {
|
|
499
|
+
if (![301, 302, 303, 307, 308].includes(response.status)) {
|
|
500
|
+
return {
|
|
501
|
+
error: `Failed to fetch URL: HTTP ${response.status} ${statusReason(response.status)}`,
|
|
502
|
+
body: "",
|
|
503
|
+
contentType: "",
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
const rawLocation = response.headers.location;
|
|
507
|
+
const location = Array.isArray(rawLocation) ? rawLocation[0] : rawLocation;
|
|
508
|
+
if (!location) {
|
|
509
|
+
return {
|
|
510
|
+
error: "Failed to fetch URL: redirect missing Location header.",
|
|
511
|
+
body: "",
|
|
512
|
+
contentType: "",
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
currentUrl = new URL(location, currentUrl).toString();
|
|
516
|
+
const [redirectAllowed, redirectReason, redirectHost] = checkUrlAccess(
|
|
517
|
+
currentUrl,
|
|
518
|
+
policy,
|
|
519
|
+
);
|
|
520
|
+
if (!redirectAllowed) return { error: redirectReason, body: "", contentType: "" };
|
|
521
|
+
const redirected = await resolveHost(redirectHost, signal);
|
|
522
|
+
if (!redirected.ok) return { error: redirected.reason, body: "", contentType: "" };
|
|
523
|
+
pinnedIp = redirected.ip;
|
|
524
|
+
pinnedFamily = redirected.family;
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
budgetError = fetchBudgetExceeded(deadline, signal, now);
|
|
529
|
+
if (budgetError !== null) return { error: budgetError, body: "", contentType: "" };
|
|
530
|
+
|
|
531
|
+
const contentTypeHeader = response.headers["content-type"];
|
|
532
|
+
const contentType = contentTypeHeader
|
|
533
|
+
? (/^[\w.+-]+\/[\w.+-]+/.exec(String(contentTypeHeader).toLowerCase()) ?? [""])[0]
|
|
534
|
+
: "";
|
|
535
|
+
const declaredCharset = contentTypeHeader
|
|
536
|
+
? (/charset=([^;\s]+)/i.exec(String(contentTypeHeader))?.[1] ?? null)
|
|
537
|
+
: null;
|
|
538
|
+
|
|
539
|
+
const declaredPdf = contentType === "application/pdf";
|
|
540
|
+
if (declaredPdf && response.body.length > maxPdfBytes) {
|
|
541
|
+
return {
|
|
542
|
+
error: "(PDF content exceeds the download limit; not readable as text)",
|
|
543
|
+
body: "",
|
|
544
|
+
contentType,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
const isPdf = declaredPdf || hasPdfMagic(response.body);
|
|
548
|
+
if (isPdf) {
|
|
549
|
+
let pdfText: string;
|
|
550
|
+
try {
|
|
551
|
+
pdfText = await extractPdfText(response.body);
|
|
552
|
+
} catch {
|
|
553
|
+
return { error: "(PDF content could not be read as text)", body: "", contentType };
|
|
554
|
+
}
|
|
555
|
+
budgetError = fetchBudgetExceeded(deadline, signal, now);
|
|
556
|
+
if (budgetError !== null) return { error: budgetError, body: "", contentType };
|
|
557
|
+
if (!pdfText) pdfText = "(PDF contains no extractable text)";
|
|
558
|
+
return { error: null, body: pdfText, contentType: "application/pdf" };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (!isTextCandidateContentType(contentType)) {
|
|
562
|
+
const safeType = /^[\w.+-]+\/[\w.+-]+/.exec(contentType ?? "")?.[0] ?? "unknown type";
|
|
563
|
+
return {
|
|
564
|
+
error: `(non-text content: ${safeType}, ${response.body.length} bytes; not readable as text)`,
|
|
565
|
+
body: "",
|
|
566
|
+
contentType,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (hasBinaryMagic(response.body)) {
|
|
571
|
+
return {
|
|
572
|
+
error: `(binary content, ${response.body.length} bytes; not readable as text)`,
|
|
573
|
+
body: "",
|
|
574
|
+
contentType,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const declaredCodec = declaredCharset ? normalizeCharset(declaredCharset) : null;
|
|
579
|
+
const bomCodec = bomCodecFor(response.body);
|
|
580
|
+
const rawHtml = decodeWithCodec(response.body, declaredCodec ?? bomCodec ?? "utf-8");
|
|
581
|
+
|
|
582
|
+
if (looksBinary(rawHtml)) {
|
|
583
|
+
let alt: string | null = null;
|
|
584
|
+
if (
|
|
585
|
+
(declaredCodec === null || declaredCodec === "iso8859-1") &&
|
|
586
|
+
hasSingleByteTextEvidence(response.body)
|
|
587
|
+
) {
|
|
588
|
+
const candidate = decodeWithCodec(response.body, "cp1252");
|
|
589
|
+
if (!looksBinary(candidate)) alt = candidate;
|
|
590
|
+
}
|
|
591
|
+
if (alt !== null) {
|
|
592
|
+
return { error: null, body: alt, contentType };
|
|
593
|
+
}
|
|
594
|
+
return {
|
|
595
|
+
error: `(binary content, ${response.body.length} bytes; not readable as text)`,
|
|
596
|
+
body: "",
|
|
597
|
+
contentType,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return { error: null, body: rawHtml, contentType };
|
|
602
|
+
}
|
|
603
|
+
return { error: "Failed to fetch URL: too many redirects.", body: "", contentType: "" };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function bomCodecFor(bytes: Buffer): string | null {
|
|
607
|
+
if (bytes.length >= 4 && bytes.subarray(0, 4).equals(UTF32_LE_BOM)) return "utf-32le";
|
|
608
|
+
if (bytes.length >= 4 && bytes.subarray(0, 4).equals(UTF32_BE_BOM)) return "utf-32be";
|
|
609
|
+
if (bytes.length >= 2 && bytes.subarray(0, 2).equals(UTF16_LE_BOM)) return "utf-16le";
|
|
610
|
+
if (bytes.length >= 2 && bytes.subarray(0, 2).equals(UTF16_BE_BOM)) return "utf-16be";
|
|
611
|
+
if (bytes.length >= 3 && bytes.subarray(0, 3).equals(UTF8_BOM)) return "utf-8";
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function statusReason(status: number): string {
|
|
616
|
+
const reasons: Record<number, string> = {
|
|
617
|
+
301: "Moved Permanently", 302: "Found", 303: "See Other", 307: "Temporary Redirect", 308: "Permanent Redirect",
|
|
618
|
+
};
|
|
619
|
+
return reasons[status] ?? "";
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export function truncatePageText(text: string, maxChars?: number): string {
|
|
623
|
+
if (!text) return "(page returned no readable text)";
|
|
624
|
+
if (typeof maxChars === "number" && maxChars > 0 && text.length > maxChars) {
|
|
625
|
+
return text.slice(0, maxChars) + `\n\n... (truncated, ${text.length} chars total)`;
|
|
626
|
+
}
|
|
627
|
+
return text;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
export async function fetchPageText(
|
|
631
|
+
url: string,
|
|
632
|
+
options: FetchPageOptions = {},
|
|
633
|
+
): Promise<string> {
|
|
634
|
+
const timeoutMs = options.timeoutMs ?? 60_000;
|
|
635
|
+
const now = options.nowMs ?? Date.now;
|
|
636
|
+
const deadlineMs = options.deadlineMs ?? now() + timeoutMs;
|
|
637
|
+
const signal = options.signal;
|
|
638
|
+
const policy = options.websitePolicy ?? null;
|
|
639
|
+
const maxChars = options.maxChars;
|
|
640
|
+
const rawFetch = options.rawFetch ?? fetchUrlRaw;
|
|
641
|
+
|
|
642
|
+
url = normalizeUrlScheme(url);
|
|
643
|
+
const [allowed, reason] = checkUrlAccess(url, policy);
|
|
644
|
+
if (!allowed) return reason;
|
|
645
|
+
|
|
646
|
+
const readmeApiUrl = githubRepoReadmeApiUrl(url);
|
|
647
|
+
if (readmeApiUrl) {
|
|
648
|
+
const readmeResult = await rawFetch(readmeApiUrl, {
|
|
649
|
+
deadlineMs,
|
|
650
|
+
signal,
|
|
651
|
+
websitePolicy: policy,
|
|
652
|
+
maxBytes: options.maxBytes,
|
|
653
|
+
maxPdfBytes: options.maxPdfBytes,
|
|
654
|
+
seams: options.seams,
|
|
655
|
+
extraHeaders: {
|
|
656
|
+
Accept: "application/vnd.github.raw+json",
|
|
657
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
658
|
+
},
|
|
659
|
+
});
|
|
660
|
+
if (readmeResult.error === null && readmeResult.body.trim()) {
|
|
661
|
+
let readmeBody = readmeResult.body;
|
|
662
|
+
if (looksLikeHtmlDocument(readmeBody)) {
|
|
663
|
+
const converted = htmlToMarkdown(readmeBody, true);
|
|
664
|
+
if (converted.trim()) readmeBody = converted;
|
|
665
|
+
}
|
|
666
|
+
if (readmeBody.trim()) {
|
|
667
|
+
return truncatePageText(
|
|
668
|
+
`README of ${url} (fetched via the GitHub README API):\n\n` + readmeBody,
|
|
669
|
+
maxChars,
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const result = await rawFetch(url, {
|
|
676
|
+
deadlineMs,
|
|
677
|
+
signal,
|
|
678
|
+
websitePolicy: policy,
|
|
679
|
+
maxBytes: options.maxBytes,
|
|
680
|
+
maxPdfBytes: options.maxPdfBytes,
|
|
681
|
+
seams: options.seams,
|
|
682
|
+
});
|
|
683
|
+
if (result.error !== null) return result.error;
|
|
684
|
+
|
|
685
|
+
const isHtml = result.contentType.includes("html") || looksLikeHtml(result.body);
|
|
686
|
+
if (!isHtml) return truncatePageText(result.body.trim(), maxChars);
|
|
687
|
+
|
|
688
|
+
return truncatePageText(htmlToMarkdown(result.body, true), maxChars);
|
|
689
|
+
}
|