polite-crawler 0.1.0 → 0.1.2
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 +601 -12
- package/build/browser-CKRNbIDT.mjs +62 -0
- package/build/browser-CKRNbIDT.mjs.map +1 -0
- package/build/cli.mjs +801 -120
- package/build/cli.mjs.map +1 -1
- package/build/diff-Dk2eSvi-.mjs +648 -0
- package/build/diff-Dk2eSvi-.mjs.map +1 -0
- package/build/duration-iIuMMofi.mjs +38 -0
- package/build/duration-iIuMMofi.mjs.map +1 -0
- package/build/extract-DcwNrTK7.mjs +1793 -0
- package/build/extract-DcwNrTK7.mjs.map +1 -0
- package/build/{html-nWYFVsRU.d.mts → html-BACljlkf.d.mts} +8 -9
- package/build/{html-oNpGAHwi.mjs → html-DkQUAE_d.mjs} +18 -39
- package/build/html-DkQUAE_d.mjs.map +1 -0
- package/build/html.d.mts +2 -1
- package/build/html.mjs +3 -1
- package/build/index.d.mts +106 -6
- package/build/index.mjs +14 -6
- package/build/log-Dx-wXjJl.mjs +342 -0
- package/build/log-Dx-wXjJl.mjs.map +1 -0
- package/build/mcp.d.mts +7 -4
- package/build/mcp.mjs +561 -35
- package/build/mcp.mjs.map +1 -1
- package/build/open-Ca1lcWR3.d.mts +2875 -0
- package/build/open-DnrWgkC0.mjs +3161 -0
- package/build/open-DnrWgkC0.mjs.map +1 -0
- package/build/{politeness-C5bZ1OKM.d.mts → politeness-yI3xBEx9.d.mts} +95 -2
- package/build/politeness.d.mts +2 -2
- package/build/politeness.mjs +102 -1
- package/build/politeness.mjs.map +1 -1
- package/build/{browser-gJ_h4AhB.mjs → render-C1CUraWF.mjs} +31 -74
- package/build/render-C1CUraWF.mjs.map +1 -0
- package/build/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/build/{open-CJR4b44v.mjs → runtime-k8mo1_7U.mjs} +1018 -1582
- package/build/runtime-k8mo1_7U.mjs.map +1 -0
- package/build/urls-BHFeSyEK.mjs +62 -0
- package/build/urls-BHFeSyEK.mjs.map +1 -0
- package/build/urls-BTDSgDLC.d.mts +16 -0
- package/build/wacz-sfOLT8e9.mjs +275 -0
- package/build/wacz-sfOLT8e9.mjs.map +1 -0
- package/build/warc-VIPscWxY.mjs +728 -0
- package/build/warc-VIPscWxY.mjs.map +1 -0
- package/build/webbotauth-CjJdTAfE.mjs +568 -0
- package/build/webbotauth-CjJdTAfE.mjs.map +1 -0
- package/package.json +1 -1
- package/record.schema.json +20 -1
- package/build/browser-gJ_h4AhB.mjs.map +0 -1
- package/build/html-oNpGAHwi.mjs.map +0 -1
- package/build/open-CJR4b44v.mjs.map +0 -1
- package/build/open-CZqwY7GH.d.mts +0 -1461
- package/build/version-rVx8_y1_.mjs +0 -11
- package/build/version-rVx8_y1_.mjs.map +0 -1
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
2
|
+
import { n as FileBlobStore, t as CaptureLog } from "./log-Dx-wXjJl.mjs";
|
|
3
|
+
import { i as VERSION, n as BOT_PAGE } from "./duration-iIuMMofi.mjs";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { open, rename, rm, stat } from "node:fs/promises";
|
|
6
|
+
import { basename, join, resolve } from "node:path";
|
|
7
|
+
import { createReadStream, createWriteStream, existsSync } from "node:fs";
|
|
8
|
+
import * as zlib from "node:zlib";
|
|
9
|
+
import { gzip } from "node:zlib";
|
|
10
|
+
import { STATUS_CODES } from "node:http";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
import { pipeline } from "node:stream/promises";
|
|
13
|
+
//#region src/zip.ts
|
|
14
|
+
const TABLE = (() => {
|
|
15
|
+
const table = /* @__PURE__ */ new Uint32Array(256);
|
|
16
|
+
for (let n = 0; n < 256; n += 1) {
|
|
17
|
+
let c = n;
|
|
18
|
+
for (let k = 0; k < 8; k += 1) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
|
|
19
|
+
table[n] = c >>> 0;
|
|
20
|
+
}
|
|
21
|
+
return table;
|
|
22
|
+
})();
|
|
23
|
+
/** This function calculates the CRC-32 of ISO 3309 with a table. It is the fallback of `crc32`. */
|
|
24
|
+
const tableCrc32 = (bytes, value = 0) => {
|
|
25
|
+
let crc = ~value >>> 0;
|
|
26
|
+
for (const byte of bytes) crc = TABLE[(crc ^ byte) & 255] ^ crc >>> 8;
|
|
27
|
+
return ~crc >>> 0;
|
|
28
|
+
};
|
|
29
|
+
const native = zlib.crc32;
|
|
30
|
+
/** This function calculates the CRC-32 of the bytes. It continues from `value`. It uses `zlib.crc32` when Node.js has it. */
|
|
31
|
+
const crc32 = (bytes, value = 0) => native === void 0 ? tableCrc32(bytes, value) : native(bytes, value);
|
|
32
|
+
/** This function writes the whole buffer. A write can write less than the buffer, and the loop writes the rest. */
|
|
33
|
+
const writeAll = async (handle, bytes) => {
|
|
34
|
+
let at = 0;
|
|
35
|
+
while (at < bytes.length) {
|
|
36
|
+
const { bytesWritten } = await handle.write(bytes, at, bytes.length - at);
|
|
37
|
+
at += bytesWritten;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
/** A tally writes a new file in sequence. It counts the bytes and calculates their CRC-32 and SHA-256. */
|
|
41
|
+
var TalliedFile = class TalliedFile {
|
|
42
|
+
path;
|
|
43
|
+
handle;
|
|
44
|
+
hash = createHash("sha256");
|
|
45
|
+
crcValue = 0;
|
|
46
|
+
count = 0;
|
|
47
|
+
constructor(path, handle) {
|
|
48
|
+
this.path = path;
|
|
49
|
+
this.handle = handle;
|
|
50
|
+
}
|
|
51
|
+
/** This method makes a new file. It fails when the file exists. */
|
|
52
|
+
static async create(path) {
|
|
53
|
+
return new TalliedFile(path, await open(path, "wx", 384));
|
|
54
|
+
}
|
|
55
|
+
/** This value is the count of bytes that the tally wrote. It is the offset of the next write. */
|
|
56
|
+
get size() {
|
|
57
|
+
return this.count;
|
|
58
|
+
}
|
|
59
|
+
async write(bytes) {
|
|
60
|
+
await writeAll(this.handle, bytes);
|
|
61
|
+
this.hash.update(bytes);
|
|
62
|
+
this.crcValue = crc32(bytes, this.crcValue);
|
|
63
|
+
this.count += bytes.length;
|
|
64
|
+
}
|
|
65
|
+
/** This method closes the file and gives its facts. */
|
|
66
|
+
async close() {
|
|
67
|
+
await this.handle.close();
|
|
68
|
+
return {
|
|
69
|
+
path: this.path,
|
|
70
|
+
size: this.count,
|
|
71
|
+
crc: this.crcValue,
|
|
72
|
+
sha256: this.hash.digest("hex")
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** This method closes the file after an error. It gives no facts. */
|
|
76
|
+
async abandon() {
|
|
77
|
+
await this.handle.close().catch(() => void 0);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
/** This function deflates a file into a new file (raw deflate, as ZIP needs it). It gives the size of the new file. */
|
|
81
|
+
const deflateFile = async (from, to) => {
|
|
82
|
+
await pipeline(createReadStream(from), zlib.createDeflateRaw(), createWriteStream(to, {
|
|
83
|
+
flags: "wx",
|
|
84
|
+
mode: 384
|
|
85
|
+
}));
|
|
86
|
+
return (await stat(to)).size;
|
|
87
|
+
};
|
|
88
|
+
/** This function makes a member from bytes in memory. A member of method 8 holds the bytes deflated. */
|
|
89
|
+
const memberOf = (name, bytes, method = 8) => {
|
|
90
|
+
const data = method === 8 ? zlib.deflateRawSync(bytes) : bytes;
|
|
91
|
+
return {
|
|
92
|
+
name,
|
|
93
|
+
method,
|
|
94
|
+
crc: crc32(bytes),
|
|
95
|
+
size: bytes.length,
|
|
96
|
+
compressedSize: data.length,
|
|
97
|
+
data
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
const MAX32 = 4294967295;
|
|
101
|
+
const MAX16 = 65535;
|
|
102
|
+
const VERSION_ZIP = 20;
|
|
103
|
+
const VERSION_ZIP64 = 45;
|
|
104
|
+
/** The member was made on Unix (3), with version 4.5 of the specification. */
|
|
105
|
+
const MADE_BY = 813;
|
|
106
|
+
/** A plain file that its owner can read and write, and the others can read (`0o100644`). */
|
|
107
|
+
const FILE_MODE = 33188 << 16 >>> 0;
|
|
108
|
+
/** Bit 11 of the flags tells that the name is UTF-8. */
|
|
109
|
+
const UTF8_NAMES = 2048;
|
|
110
|
+
const dosTime = (date) => {
|
|
111
|
+
const year = Math.min(Math.max(date.getUTCFullYear(), 1980), 2107);
|
|
112
|
+
return {
|
|
113
|
+
time: date.getUTCHours() << 11 | date.getUTCMinutes() << 5 | date.getUTCSeconds() >> 1,
|
|
114
|
+
day: year - 1980 << 9 | date.getUTCMonth() + 1 << 5 | date.getUTCDate()
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
/** This function makes the Zip64 extra field (id 0x0001) with these values of 64 bits. */
|
|
118
|
+
const zip64Extra = (values) => {
|
|
119
|
+
const extra = Buffer.alloc(4 + 8 * values.length);
|
|
120
|
+
extra.writeUInt16LE(1, 0);
|
|
121
|
+
extra.writeUInt16LE(8 * values.length, 2);
|
|
122
|
+
values.forEach((value, at) => extra.writeBigUInt64LE(BigInt(value), 4 + 8 * at));
|
|
123
|
+
return extra;
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* This function writes a ZIP file with these members, in this sequence. It writes a new file and
|
|
127
|
+
* fails when the file exists. It gives the size of the ZIP file.
|
|
128
|
+
*/
|
|
129
|
+
const writeZip = async (target, members, options) => {
|
|
130
|
+
const always = options.zip64 === "always";
|
|
131
|
+
const { time, day } = dosTime(options.date);
|
|
132
|
+
const handle = await open(target, "wx", 384);
|
|
133
|
+
let offset = 0;
|
|
134
|
+
const put = async (bytes) => {
|
|
135
|
+
await writeAll(handle, bytes);
|
|
136
|
+
offset += bytes.length;
|
|
137
|
+
};
|
|
138
|
+
try {
|
|
139
|
+
const central = [];
|
|
140
|
+
for (const member of members) {
|
|
141
|
+
const name = Buffer.from(member.name, "utf8");
|
|
142
|
+
const start = offset;
|
|
143
|
+
const bigSizes = always || member.size >= MAX32 || member.compressedSize >= MAX32;
|
|
144
|
+
const localExtra = bigSizes ? zip64Extra([member.size, member.compressedSize]) : Buffer.alloc(0);
|
|
145
|
+
const local = Buffer.alloc(30);
|
|
146
|
+
local.writeUInt32LE(67324752, 0);
|
|
147
|
+
local.writeUInt16LE(bigSizes ? VERSION_ZIP64 : VERSION_ZIP, 4);
|
|
148
|
+
local.writeUInt16LE(UTF8_NAMES, 6);
|
|
149
|
+
local.writeUInt16LE(member.method, 8);
|
|
150
|
+
local.writeUInt16LE(time, 10);
|
|
151
|
+
local.writeUInt16LE(day, 12);
|
|
152
|
+
local.writeUInt32LE(member.crc >>> 0, 14);
|
|
153
|
+
local.writeUInt32LE(bigSizes ? MAX32 : member.compressedSize, 18);
|
|
154
|
+
local.writeUInt32LE(bigSizes ? MAX32 : member.size, 22);
|
|
155
|
+
local.writeUInt16LE(name.length, 26);
|
|
156
|
+
local.writeUInt16LE(localExtra.length, 28);
|
|
157
|
+
await put(local);
|
|
158
|
+
await put(name);
|
|
159
|
+
await put(localExtra);
|
|
160
|
+
const before = offset;
|
|
161
|
+
if (member.file !== void 0) for await (const chunk of createReadStream(member.file)) await put(chunk);
|
|
162
|
+
else await put(member.data ?? /* @__PURE__ */ new Uint8Array());
|
|
163
|
+
if (offset - before !== member.compressedSize) throw new Error(`the member ${member.name} has ${String(offset - before)} bytes, not ${String(member.compressedSize)}`);
|
|
164
|
+
const bigSize = always || member.size >= MAX32;
|
|
165
|
+
const bigCompressed = always || member.compressedSize >= MAX32;
|
|
166
|
+
const bigOffset = always || start >= MAX32;
|
|
167
|
+
const values = [
|
|
168
|
+
...bigSize ? [member.size] : [],
|
|
169
|
+
...bigCompressed ? [member.compressedSize] : [],
|
|
170
|
+
...bigOffset ? [start] : []
|
|
171
|
+
];
|
|
172
|
+
const extra = values.length > 0 ? zip64Extra(values) : Buffer.alloc(0);
|
|
173
|
+
const header = Buffer.alloc(46);
|
|
174
|
+
header.writeUInt32LE(33639248, 0);
|
|
175
|
+
header.writeUInt16LE(MADE_BY, 4);
|
|
176
|
+
header.writeUInt16LE(values.length > 0 ? VERSION_ZIP64 : VERSION_ZIP, 6);
|
|
177
|
+
header.writeUInt16LE(UTF8_NAMES, 8);
|
|
178
|
+
header.writeUInt16LE(member.method, 10);
|
|
179
|
+
header.writeUInt16LE(time, 12);
|
|
180
|
+
header.writeUInt16LE(day, 14);
|
|
181
|
+
header.writeUInt32LE(member.crc >>> 0, 16);
|
|
182
|
+
header.writeUInt32LE(bigCompressed ? MAX32 : member.compressedSize, 20);
|
|
183
|
+
header.writeUInt32LE(bigSize ? MAX32 : member.size, 24);
|
|
184
|
+
header.writeUInt16LE(name.length, 28);
|
|
185
|
+
header.writeUInt16LE(extra.length, 30);
|
|
186
|
+
header.writeUInt16LE(0, 32);
|
|
187
|
+
header.writeUInt16LE(0, 34);
|
|
188
|
+
header.writeUInt16LE(0, 36);
|
|
189
|
+
header.writeUInt32LE(FILE_MODE, 38);
|
|
190
|
+
header.writeUInt32LE(bigOffset ? MAX32 : start, 42);
|
|
191
|
+
central.push(header, name, extra);
|
|
192
|
+
}
|
|
193
|
+
const centralStart = offset;
|
|
194
|
+
for (const part of central) await put(part);
|
|
195
|
+
const centralSize = offset - centralStart;
|
|
196
|
+
const count = members.length;
|
|
197
|
+
const zip64End = always || count >= MAX16 || centralStart >= MAX32 || centralSize >= MAX32;
|
|
198
|
+
if (zip64End) {
|
|
199
|
+
const recordAt = offset;
|
|
200
|
+
const record = Buffer.alloc(56);
|
|
201
|
+
record.writeUInt32LE(101075792, 0);
|
|
202
|
+
record.writeBigUInt64LE(44n, 4);
|
|
203
|
+
record.writeUInt16LE(MADE_BY, 12);
|
|
204
|
+
record.writeUInt16LE(VERSION_ZIP64, 14);
|
|
205
|
+
record.writeUInt32LE(0, 16);
|
|
206
|
+
record.writeUInt32LE(0, 20);
|
|
207
|
+
record.writeBigUInt64LE(BigInt(count), 24);
|
|
208
|
+
record.writeBigUInt64LE(BigInt(count), 32);
|
|
209
|
+
record.writeBigUInt64LE(BigInt(centralSize), 40);
|
|
210
|
+
record.writeBigUInt64LE(BigInt(centralStart), 48);
|
|
211
|
+
await put(record);
|
|
212
|
+
const locator = Buffer.alloc(20);
|
|
213
|
+
locator.writeUInt32LE(117853008, 0);
|
|
214
|
+
locator.writeUInt32LE(0, 4);
|
|
215
|
+
locator.writeBigUInt64LE(BigInt(recordAt), 8);
|
|
216
|
+
locator.writeUInt32LE(1, 16);
|
|
217
|
+
await put(locator);
|
|
218
|
+
}
|
|
219
|
+
const end = Buffer.alloc(22);
|
|
220
|
+
end.writeUInt32LE(101010256, 0);
|
|
221
|
+
end.writeUInt16LE(0, 4);
|
|
222
|
+
end.writeUInt16LE(0, 6);
|
|
223
|
+
end.writeUInt16LE(zip64End ? MAX16 : count, 8);
|
|
224
|
+
end.writeUInt16LE(zip64End ? MAX16 : count, 10);
|
|
225
|
+
end.writeUInt32LE(zip64End ? MAX32 : centralSize, 12);
|
|
226
|
+
end.writeUInt32LE(zip64End ? MAX32 : centralStart, 16);
|
|
227
|
+
end.writeUInt16LE(0, 20);
|
|
228
|
+
await put(end);
|
|
229
|
+
} finally {
|
|
230
|
+
await handle.close();
|
|
231
|
+
}
|
|
232
|
+
return offset;
|
|
233
|
+
};
|
|
234
|
+
//#endregion
|
|
235
|
+
//#region src/warc.ts
|
|
236
|
+
var warc_exports = /* @__PURE__ */ __exportAll({
|
|
237
|
+
base32: () => base32,
|
|
238
|
+
cdxjLine: () => cdxjLine,
|
|
239
|
+
exportWarc: () => exportWarc,
|
|
240
|
+
outputOf: () => outputOf,
|
|
241
|
+
planArchive: () => planArchive,
|
|
242
|
+
surt: () => surt,
|
|
243
|
+
writeArchive: () => writeArchive
|
|
244
|
+
});
|
|
245
|
+
const gzipAsync = promisify(gzip);
|
|
246
|
+
const HOME = new URL("../", BOT_PAGE).href;
|
|
247
|
+
const CRLF = "\r\n";
|
|
248
|
+
const END = Buffer.from("\r\n\r\n", "latin1");
|
|
249
|
+
const KINDS = /* @__PURE__ */ new Set([
|
|
250
|
+
"capture",
|
|
251
|
+
"revisit",
|
|
252
|
+
"skipped"
|
|
253
|
+
]);
|
|
254
|
+
const HASH = /^[0-9a-f]{64}$/;
|
|
255
|
+
/** The revisit records name their original from this count of recent responses. */
|
|
256
|
+
const ORIGINALS = 65536;
|
|
257
|
+
const SERVER_NOT_MODIFIED = "http://netpreserve.org/warc/1.1/revisit/server-not-modified";
|
|
258
|
+
const HTTP_RESPONSE = "application/http; msgtype=response";
|
|
259
|
+
const RFC4648 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
260
|
+
/** This function writes bytes in base 32 (RFC 4648), with capitals and padding. WARC tools write SHA-256 digests in this form. */
|
|
261
|
+
const base32 = (bytes) => {
|
|
262
|
+
let out = "";
|
|
263
|
+
let value = 0;
|
|
264
|
+
let bits = 0;
|
|
265
|
+
for (const byte of bytes) {
|
|
266
|
+
value = (value << 8 | byte) & 65535;
|
|
267
|
+
bits += 8;
|
|
268
|
+
while (bits >= 5) {
|
|
269
|
+
out += RFC4648[value >>> bits - 5 & 31];
|
|
270
|
+
bits -= 5;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (bits > 0) out += RFC4648[value << 5 - bits & 31];
|
|
274
|
+
while (out.length % 8 !== 0) out += "=";
|
|
275
|
+
return out;
|
|
276
|
+
};
|
|
277
|
+
const digestOf = (bytes) => `sha256:${base32(bytes)}`;
|
|
278
|
+
const sha256Of = (...parts) => {
|
|
279
|
+
const hash = createHash("sha256");
|
|
280
|
+
for (const part of parts) hash.update(part);
|
|
281
|
+
return hash.digest();
|
|
282
|
+
};
|
|
283
|
+
/** This function gives a version 5 UUID (RFC 9562): the SHA-1 of a namespace and a name. */
|
|
284
|
+
const uuidV5 = (namespace, name) => {
|
|
285
|
+
const hash = createHash("sha1").update(Buffer.from(namespace.replace(/-/g, ""), "hex")).update(name, "utf8").digest();
|
|
286
|
+
hash[6] = hash[6] & 15 | 80;
|
|
287
|
+
hash[8] = hash[8] & 63 | 128;
|
|
288
|
+
const hex = hash.subarray(0, 16).toString("hex");
|
|
289
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
290
|
+
};
|
|
291
|
+
/** The namespace of the record ids: a UUID of the URL of the site, in the URL namespace of RFC 9562. */
|
|
292
|
+
const NAMESPACE = uuidV5("6ba7b811-9dad-11d1-80b4-00c04fd430c8", HOME);
|
|
293
|
+
/** The id of a record of a capture is the same at each export: it comes from the hash of the capture. */
|
|
294
|
+
const recordId = (record, type) => `<urn:uuid:${uuidV5(NAMESPACE, `${record.content_hash || `${record.id}@${record.received_at}`}/${type}`)}>`;
|
|
295
|
+
/** A field of a header holds no line break. */
|
|
296
|
+
const clean = (value) => value.replace(/[\r\n]+/g, " ").trim();
|
|
297
|
+
/** This function gives one record: the WARC header, the block and the two line breaks at the end. */
|
|
298
|
+
const recordBytes = (fields, block) => {
|
|
299
|
+
const length = block.reduce((sum, part) => sum + part.length, 0);
|
|
300
|
+
const head = [
|
|
301
|
+
"WARC/1.1",
|
|
302
|
+
...fields.map(([name, value]) => `${name}: ${clean(value)}`),
|
|
303
|
+
`Content-Length: ${String(length)}`,
|
|
304
|
+
"",
|
|
305
|
+
""
|
|
306
|
+
].join(CRLF);
|
|
307
|
+
return Buffer.concat([
|
|
308
|
+
Buffer.from(head, "utf8"),
|
|
309
|
+
...block,
|
|
310
|
+
END
|
|
311
|
+
]);
|
|
312
|
+
};
|
|
313
|
+
/**
|
|
314
|
+
* This function gives an HTTP status line and header block. HTTP headers are Latin-1 bytes, and
|
|
315
|
+
* undici gives each byte as one character. Thus the block gives back the bytes that the server sent.
|
|
316
|
+
*/
|
|
317
|
+
const httpHead = (status, headers) => Buffer.from([
|
|
318
|
+
`HTTP/1.1 ${String(status)} ${STATUS_CODES[status] ?? "Unknown"}`,
|
|
319
|
+
...headers.map(([name, value]) => `${name}: ${clean(value)}`),
|
|
320
|
+
"",
|
|
321
|
+
""
|
|
322
|
+
].join(CRLF), "latin1");
|
|
323
|
+
/** These fields of a capture keep a header of the response. */
|
|
324
|
+
const KEPT_HEADERS = [
|
|
325
|
+
["etag", "ETag"],
|
|
326
|
+
["last_modified", "Last-Modified"],
|
|
327
|
+
["cache_control", "Cache-Control"],
|
|
328
|
+
["expires", "Expires"],
|
|
329
|
+
["age", "Age"],
|
|
330
|
+
["date", "Date"],
|
|
331
|
+
["vary", "Vary"]
|
|
332
|
+
];
|
|
333
|
+
const text = (value) => typeof value === "string" && value.length > 0 ? value : void 0;
|
|
334
|
+
/** This function gives the time of a capture in ISO 8601, with milliseconds. WARC/1.1 permits them. */
|
|
335
|
+
const isoOf = (value, fallback) => {
|
|
336
|
+
const ms = typeof value === "string" ? Date.parse(value) : NaN;
|
|
337
|
+
if (Number.isFinite(ms)) return new Date(ms).toISOString();
|
|
338
|
+
const other = Date.parse(fallback);
|
|
339
|
+
return Number.isFinite(other) ? new Date(other).toISOString() : fallback;
|
|
340
|
+
};
|
|
341
|
+
const factsOf = (record) => {
|
|
342
|
+
const payload = record.payload;
|
|
343
|
+
if (typeof payload !== "object" || payload === null) return void 0;
|
|
344
|
+
const fields = payload;
|
|
345
|
+
const url = text(fields["url"]) ?? text(record.external_id);
|
|
346
|
+
if (url === void 0) return void 0;
|
|
347
|
+
const blob = text(fields["blob"]);
|
|
348
|
+
const status = fields["status"];
|
|
349
|
+
return {
|
|
350
|
+
url,
|
|
351
|
+
date: isoOf(fields["fetched_at"], record.received_at),
|
|
352
|
+
status: typeof status === "number" && Number.isInteger(status) ? status : void 0,
|
|
353
|
+
blob: blob !== void 0 && HASH.test(blob) ? blob : void 0,
|
|
354
|
+
contentType: text(fields["content_type"]),
|
|
355
|
+
rendered: fields["rendered"] === true,
|
|
356
|
+
truncated: fields["truncated"] === true,
|
|
357
|
+
cut: fields["cut"] === true,
|
|
358
|
+
revisitOf: text(fields["revisit_of"]),
|
|
359
|
+
recipe: text(fields["recipe"]),
|
|
360
|
+
headers: KEPT_HEADERS.flatMap(([field, name]) => {
|
|
361
|
+
const value = text(fields[field]);
|
|
362
|
+
return value === void 0 ? [] : [[name, value]];
|
|
363
|
+
})
|
|
364
|
+
};
|
|
365
|
+
};
|
|
366
|
+
const timeOf = (name, value) => {
|
|
367
|
+
if (value === void 0) return void 0;
|
|
368
|
+
const ms = value instanceof Date ? value.getTime() : Date.parse(value);
|
|
369
|
+
if (!Number.isFinite(ms)) throw new RangeError(`${name} is not a time: "${String(value)}"`);
|
|
370
|
+
return new Date(ms).toISOString();
|
|
371
|
+
};
|
|
372
|
+
const hostName = (value) => {
|
|
373
|
+
try {
|
|
374
|
+
return new URL(value).hostname.toLowerCase().replace(/\.$/, "");
|
|
375
|
+
} catch {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
const filterOf = (options) => {
|
|
380
|
+
const since = timeOf("since", options.since);
|
|
381
|
+
const until = timeOf("until", options.until);
|
|
382
|
+
let host;
|
|
383
|
+
if (options.host !== void 0) {
|
|
384
|
+
host = options.host.trim().toLowerCase().replace(/^\*\./, "").replace(/\.$/, "");
|
|
385
|
+
if (host === "" || /[/:@\s]/.test(host)) throw new RangeError(`host is a host name, for example example.com, not "${options.host}"`);
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
since,
|
|
389
|
+
until,
|
|
390
|
+
host,
|
|
391
|
+
urlPrefix: options.urlPrefix === "" ? void 0 : options.urlPrefix,
|
|
392
|
+
ids: options.ids === void 0 ? void 0 : new Set(options.ids)
|
|
393
|
+
};
|
|
394
|
+
};
|
|
395
|
+
const selected = (record, facts, filter) => {
|
|
396
|
+
if (filter.since !== void 0 && record.received_at < filter.since) return false;
|
|
397
|
+
if (filter.until !== void 0 && record.received_at >= filter.until) return false;
|
|
398
|
+
if (filter.urlPrefix !== void 0 && !facts.url.startsWith(filter.urlPrefix)) return false;
|
|
399
|
+
if (filter.ids !== void 0 && !filter.ids.has(record.id)) return false;
|
|
400
|
+
if (filter.host !== void 0) {
|
|
401
|
+
const host = hostName(facts.url);
|
|
402
|
+
if (host === void 0 || host !== filter.host && !host.endsWith(`.${filter.host}`)) return false;
|
|
403
|
+
}
|
|
404
|
+
return true;
|
|
405
|
+
};
|
|
406
|
+
/**
|
|
407
|
+
* This function checks the filters and opens the source. It writes nothing. A folder must hold a
|
|
408
|
+
* capture log, and the export does not make a store.
|
|
409
|
+
*/
|
|
410
|
+
const planArchive = (source, options) => {
|
|
411
|
+
const filter = filterOf(options);
|
|
412
|
+
const includeRefusals = options.includeRefusals === true;
|
|
413
|
+
if (typeof source !== "string") return {
|
|
414
|
+
captures: source.captures,
|
|
415
|
+
blobs: source.blobs,
|
|
416
|
+
filter,
|
|
417
|
+
includeRefusals
|
|
418
|
+
};
|
|
419
|
+
const dir = resolve(source);
|
|
420
|
+
if (!existsSync(join(dir, "captures"))) throw new Error(`the store ${dir} has no capture log (captures/)`);
|
|
421
|
+
return {
|
|
422
|
+
captures: new CaptureLog(join(dir, "captures"), { sync: false }),
|
|
423
|
+
blobs: new FileBlobStore(join(dir, "blobs"), { sync: false }),
|
|
424
|
+
filter,
|
|
425
|
+
includeRefusals
|
|
426
|
+
};
|
|
427
|
+
};
|
|
428
|
+
/** This function gives the path of the archive and of its temporary file. It fails when the archive exists, unless `overwrite` is true. */
|
|
429
|
+
const outputOf = (options) => {
|
|
430
|
+
if (typeof options.out !== "string" || options.out.trim() === "") throw new Error("an export needs out: the file of the archive");
|
|
431
|
+
const target = resolve(options.out);
|
|
432
|
+
if (options.overwrite !== true && existsSync(target)) throw new Error(`${target} exists: give a different file, or set overwrite`);
|
|
433
|
+
return {
|
|
434
|
+
target,
|
|
435
|
+
temporary: `${target}.${randomUUID()}.tmp`
|
|
436
|
+
};
|
|
437
|
+
};
|
|
438
|
+
/** This function reads a body from the blob store. It gives no body when the bytes do not agree with the hash. */
|
|
439
|
+
const bodyOf = async (blobs, hash) => {
|
|
440
|
+
let bytes;
|
|
441
|
+
try {
|
|
442
|
+
bytes = await blobs.get(hash);
|
|
443
|
+
} catch {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (bytes === void 0) return void 0;
|
|
447
|
+
const digest = sha256Of(bytes);
|
|
448
|
+
return digest.toString("hex") === hash ? {
|
|
449
|
+
bytes,
|
|
450
|
+
digest
|
|
451
|
+
} : void 0;
|
|
452
|
+
};
|
|
453
|
+
const mimeOf = (contentType) => contentType?.split(";")[0]?.trim().toLowerCase() || "-";
|
|
454
|
+
/** This function gives the JSON of the metadata record of a capture. The labels and the scope of the application stay out. */
|
|
455
|
+
const metadataOf = (record, written) => Buffer.from(`${JSON.stringify({
|
|
456
|
+
capture: {
|
|
457
|
+
id: record.id,
|
|
458
|
+
source: record.source,
|
|
459
|
+
kind: record.kind,
|
|
460
|
+
...record.external_id !== void 0 ? { external_id: record.external_id } : {},
|
|
461
|
+
...record.occurred_at !== void 0 ? { occurred_at: record.occurred_at } : {},
|
|
462
|
+
received_at: record.received_at,
|
|
463
|
+
content_hash: record.content_hash,
|
|
464
|
+
payload: record.payload
|
|
465
|
+
},
|
|
466
|
+
export: written
|
|
467
|
+
})}\n`, "utf8");
|
|
468
|
+
/**
|
|
469
|
+
* This function writes the records of an archive into a file: the `warcinfo` record, then the
|
|
470
|
+
* records of each capture that the plan selects. It gives the facts of each `response` and
|
|
471
|
+
* `revisit` record to `onRecord`, for the index of a WACZ file.
|
|
472
|
+
*/
|
|
473
|
+
const writeArchive = async (plan, file, filename, onRecord) => {
|
|
474
|
+
const counts = {
|
|
475
|
+
captures: 0,
|
|
476
|
+
responses: 0,
|
|
477
|
+
revisits: 0,
|
|
478
|
+
conversions: 0,
|
|
479
|
+
metadata: 0,
|
|
480
|
+
bodiless: 0,
|
|
481
|
+
missing: 0,
|
|
482
|
+
damaged: 0
|
|
483
|
+
};
|
|
484
|
+
const { filter } = plan;
|
|
485
|
+
const put = async (bytes) => {
|
|
486
|
+
const member = await gzipAsync(bytes);
|
|
487
|
+
const offset = file.size;
|
|
488
|
+
await file.write(member);
|
|
489
|
+
return {
|
|
490
|
+
offset,
|
|
491
|
+
length: member.length
|
|
492
|
+
};
|
|
493
|
+
};
|
|
494
|
+
const info = `<urn:uuid:${randomUUID()}>`;
|
|
495
|
+
const shown = Object.fromEntries(Object.entries({
|
|
496
|
+
since: filter.since,
|
|
497
|
+
until: filter.until,
|
|
498
|
+
host: filter.host,
|
|
499
|
+
url_prefix: filter.urlPrefix,
|
|
500
|
+
ids: filter.ids === void 0 ? void 0 : filter.ids.size,
|
|
501
|
+
include_refusals: plan.includeRefusals
|
|
502
|
+
}).filter(([, value]) => value !== void 0));
|
|
503
|
+
const fields = [
|
|
504
|
+
`software: polite-crawler/${VERSION} (+${HOME})`,
|
|
505
|
+
"format: WARC File Format 1.1",
|
|
506
|
+
"conformsTo: https://iipc.github.io/warc-specifications/specifications/warc-format/warc-1.1/",
|
|
507
|
+
"robots: obey",
|
|
508
|
+
"description: An export of the capture log of polite-crawler. The HTTP headers of each response are rebuilt from the headers that the capture kept.",
|
|
509
|
+
`polite-crawler-filter: ${JSON.stringify(shown)}`
|
|
510
|
+
];
|
|
511
|
+
await put(recordBytes([
|
|
512
|
+
["WARC-Type", "warcinfo"],
|
|
513
|
+
["WARC-Record-ID", info],
|
|
514
|
+
["WARC-Date", (/* @__PURE__ */ new Date()).toISOString()],
|
|
515
|
+
["WARC-Filename", filename],
|
|
516
|
+
["Content-Type", "application/warc-fields"]
|
|
517
|
+
], [Buffer.from(`${fields.join(CRLF)}${CRLF}`, "utf8")]));
|
|
518
|
+
const originals = /* @__PURE__ */ new Map();
|
|
519
|
+
const remember = (capture, original) => {
|
|
520
|
+
originals.delete(capture);
|
|
521
|
+
originals.set(capture, original);
|
|
522
|
+
if (originals.size > ORIGINALS) originals.delete(originals.keys().next().value);
|
|
523
|
+
};
|
|
524
|
+
const scan = plan.captures.scan({
|
|
525
|
+
source: "fetch",
|
|
526
|
+
since: filter.since
|
|
527
|
+
}, { onDamaged: () => void (counts.damaged += 1) });
|
|
528
|
+
for await (const record of scan) {
|
|
529
|
+
if (record.source !== "fetch" || !KINDS.has(record.kind)) continue;
|
|
530
|
+
const facts = factsOf(record);
|
|
531
|
+
if (facts === void 0 || !selected(record, facts, filter)) continue;
|
|
532
|
+
counts.captures += 1;
|
|
533
|
+
const common = [
|
|
534
|
+
["WARC-Date", facts.date],
|
|
535
|
+
["WARC-Target-URI", facts.url],
|
|
536
|
+
["WARC-Warcinfo-ID", info]
|
|
537
|
+
];
|
|
538
|
+
const vendor = [["WARC-Polite-Crawler-Capture", record.id]];
|
|
539
|
+
let main;
|
|
540
|
+
let written = {
|
|
541
|
+
record: "none",
|
|
542
|
+
reason: "no body"
|
|
543
|
+
};
|
|
544
|
+
if (record.kind === "revisit" && facts.blob !== void 0) {
|
|
545
|
+
const original = facts.revisitOf === void 0 ? void 0 : originals.get(facts.revisitOf);
|
|
546
|
+
const head = httpHead(304, [...facts.contentType !== void 0 ? [["Content-Type", facts.contentType]] : [], ...facts.headers]);
|
|
547
|
+
const digest = digestOf(Buffer.from(facts.blob, "hex"));
|
|
548
|
+
main = recordId(record, "revisit");
|
|
549
|
+
const at = await put(recordBytes([
|
|
550
|
+
["WARC-Type", "revisit"],
|
|
551
|
+
["WARC-Record-ID", main],
|
|
552
|
+
...common,
|
|
553
|
+
["WARC-Profile", SERVER_NOT_MODIFIED],
|
|
554
|
+
...original !== void 0 ? [["WARC-Refers-To", original.id]] : [],
|
|
555
|
+
["WARC-Refers-To-Target-URI", original?.url ?? facts.url],
|
|
556
|
+
...original !== void 0 ? [["WARC-Refers-To-Date", original.date]] : [],
|
|
557
|
+
["WARC-Payload-Digest", digest],
|
|
558
|
+
["WARC-Block-Digest", digestOf(sha256Of(head))],
|
|
559
|
+
...vendor,
|
|
560
|
+
["WARC-Polite-Crawler-Headers", "rebuilt"],
|
|
561
|
+
["Content-Type", HTTP_RESPONSE]
|
|
562
|
+
], [head]));
|
|
563
|
+
counts.revisits += 1;
|
|
564
|
+
written = {
|
|
565
|
+
record: "revisit",
|
|
566
|
+
http_headers: "rebuilt"
|
|
567
|
+
};
|
|
568
|
+
await onRecord?.({
|
|
569
|
+
type: "revisit",
|
|
570
|
+
url: facts.url,
|
|
571
|
+
date: facts.date,
|
|
572
|
+
status: 304,
|
|
573
|
+
mime: "warc/revisit",
|
|
574
|
+
digest,
|
|
575
|
+
...at,
|
|
576
|
+
capture: record.id,
|
|
577
|
+
page: false
|
|
578
|
+
});
|
|
579
|
+
} else if (record.kind === "capture" && facts.blob !== void 0) {
|
|
580
|
+
const body = await bodyOf(plan.blobs, facts.blob);
|
|
581
|
+
if (body === void 0) {
|
|
582
|
+
counts.missing += 1;
|
|
583
|
+
written = {
|
|
584
|
+
record: "none",
|
|
585
|
+
reason: "body missing"
|
|
586
|
+
};
|
|
587
|
+
} else if (facts.rendered) {
|
|
588
|
+
const digest = digestOf(body.digest);
|
|
589
|
+
main = recordId(record, "conversion");
|
|
590
|
+
await put(recordBytes([
|
|
591
|
+
["WARC-Type", "conversion"],
|
|
592
|
+
["WARC-Record-ID", main],
|
|
593
|
+
...common,
|
|
594
|
+
["WARC-Payload-Digest", digest],
|
|
595
|
+
["WARC-Block-Digest", digest],
|
|
596
|
+
...vendor,
|
|
597
|
+
["Content-Type", facts.contentType ?? "text/html; charset=utf-8"]
|
|
598
|
+
], [body.bytes]));
|
|
599
|
+
counts.conversions += 1;
|
|
600
|
+
written = {
|
|
601
|
+
record: "conversion",
|
|
602
|
+
body: "rendered"
|
|
603
|
+
};
|
|
604
|
+
} else if (facts.status !== void 0 && facts.status >= 100 && facts.status <= 999) {
|
|
605
|
+
const head = httpHead(facts.status, [
|
|
606
|
+
...facts.contentType !== void 0 ? [["Content-Type", facts.contentType]] : [],
|
|
607
|
+
["Content-Length", String(body.bytes.length)],
|
|
608
|
+
...facts.headers
|
|
609
|
+
]);
|
|
610
|
+
const digest = digestOf(body.digest);
|
|
611
|
+
main = recordId(record, "response");
|
|
612
|
+
const truncated = facts.cut ? "disconnect" : facts.truncated ? "length" : void 0;
|
|
613
|
+
const at = await put(recordBytes([
|
|
614
|
+
["WARC-Type", "response"],
|
|
615
|
+
["WARC-Record-ID", main],
|
|
616
|
+
...common,
|
|
617
|
+
["WARC-Payload-Digest", digest],
|
|
618
|
+
["WARC-Block-Digest", digestOf(sha256Of(head, body.bytes))],
|
|
619
|
+
...truncated !== void 0 ? [["WARC-Truncated", truncated]] : [],
|
|
620
|
+
...vendor,
|
|
621
|
+
["WARC-Polite-Crawler-Headers", "rebuilt"],
|
|
622
|
+
["Content-Type", HTTP_RESPONSE]
|
|
623
|
+
], [head, body.bytes]));
|
|
624
|
+
counts.responses += 1;
|
|
625
|
+
written = {
|
|
626
|
+
record: "response",
|
|
627
|
+
http_headers: "rebuilt",
|
|
628
|
+
body: "decoded"
|
|
629
|
+
};
|
|
630
|
+
remember(record.id, {
|
|
631
|
+
id: main,
|
|
632
|
+
date: facts.date,
|
|
633
|
+
url: facts.url
|
|
634
|
+
});
|
|
635
|
+
await onRecord?.({
|
|
636
|
+
type: "response",
|
|
637
|
+
url: facts.url,
|
|
638
|
+
date: facts.date,
|
|
639
|
+
status: facts.status,
|
|
640
|
+
mime: mimeOf(facts.contentType),
|
|
641
|
+
digest,
|
|
642
|
+
...at,
|
|
643
|
+
capture: record.id,
|
|
644
|
+
page: facts.status === 200 && facts.recipe !== "robots.txt"
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if (main === void 0) {
|
|
649
|
+
if (written["reason"] === "no body") counts.bodiless += 1;
|
|
650
|
+
if (!plan.includeRefusals) continue;
|
|
651
|
+
}
|
|
652
|
+
const block = metadataOf(record, written);
|
|
653
|
+
await put(recordBytes([
|
|
654
|
+
["WARC-Type", "metadata"],
|
|
655
|
+
["WARC-Record-ID", recordId(record, "metadata")],
|
|
656
|
+
...common,
|
|
657
|
+
...main !== void 0 ? [["WARC-Concurrent-To", main]] : [],
|
|
658
|
+
["WARC-Block-Digest", digestOf(sha256Of(block))],
|
|
659
|
+
...vendor,
|
|
660
|
+
["Content-Type", "application/json"]
|
|
661
|
+
], [block]));
|
|
662
|
+
counts.metadata += 1;
|
|
663
|
+
}
|
|
664
|
+
return counts;
|
|
665
|
+
};
|
|
666
|
+
/**
|
|
667
|
+
* This function gives the SURT key of a URL, as `pywb` and the `surt` library make it with their
|
|
668
|
+
* defaults. The host is reversed with commas and has no `www.` and no default port. The path
|
|
669
|
+
* and the query are lowercase, the path has no slash at the end, and the parts of the query are
|
|
670
|
+
* sorted.
|
|
671
|
+
*/
|
|
672
|
+
const surt = (value) => {
|
|
673
|
+
let url;
|
|
674
|
+
try {
|
|
675
|
+
url = new URL(value);
|
|
676
|
+
} catch {
|
|
677
|
+
return value.toLowerCase();
|
|
678
|
+
}
|
|
679
|
+
const host = url.hostname.toLowerCase().replace(/\.$/, "").replace(/^www\d*\./, "");
|
|
680
|
+
const key = host.startsWith("[") ? host : host.split(".").reverse().join(",");
|
|
681
|
+
let path = url.pathname.toLowerCase();
|
|
682
|
+
if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
|
|
683
|
+
const query = url.search.slice(1).toLowerCase().split("&").filter(Boolean).sort().join("&");
|
|
684
|
+
return `${key}${url.port === "" ? "" : `:${url.port}`})${path}${query === "" ? "" : `?${query}`}`;
|
|
685
|
+
};
|
|
686
|
+
/** This function gives the line of a record in a CDXJ index: the SURT key, the time to the second, and JSON. */
|
|
687
|
+
const cdxjLine = (record, filename) => `${surt(record.url)} ${record.date.slice(0, 19).replace(/\D/g, "")} ${JSON.stringify({
|
|
688
|
+
url: record.url,
|
|
689
|
+
mime: record.mime,
|
|
690
|
+
status: String(record.status),
|
|
691
|
+
digest: record.digest,
|
|
692
|
+
length: String(record.length),
|
|
693
|
+
offset: String(record.offset),
|
|
694
|
+
filename
|
|
695
|
+
})}`;
|
|
696
|
+
/**
|
|
697
|
+
* This function writes the captures of a store as a WARC/1.1 file, with one gzip member for each
|
|
698
|
+
* record (`.warc.gz`). It reads the capture log as a stream, so its memory does not grow with the
|
|
699
|
+
* store. It writes a temporary file and gives it the name `out` at the end, so a failed export
|
|
700
|
+
* leaves no part of a file.
|
|
701
|
+
*
|
|
702
|
+
* The source is the folder of a store, or the capture log and the blob store of a runtime
|
|
703
|
+
* (`crawler.runtime`). The export only reads the store.
|
|
704
|
+
*/
|
|
705
|
+
const exportWarc = async (source, options) => {
|
|
706
|
+
const plan = planArchive(source, options);
|
|
707
|
+
const { target, temporary } = outputOf(options);
|
|
708
|
+
const file = await TalliedFile.create(temporary);
|
|
709
|
+
try {
|
|
710
|
+
const counts = await writeArchive(plan, file, basename(target));
|
|
711
|
+
const done = await file.close();
|
|
712
|
+
await rename(temporary, target);
|
|
713
|
+
return {
|
|
714
|
+
format: "warc",
|
|
715
|
+
out: target,
|
|
716
|
+
bytes: done.size,
|
|
717
|
+
...counts
|
|
718
|
+
};
|
|
719
|
+
} catch (error) {
|
|
720
|
+
await file.abandon();
|
|
721
|
+
await rm(temporary, { force: true }).catch(() => void 0);
|
|
722
|
+
throw error;
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
//#endregion
|
|
726
|
+
export { warc_exports as a, deflateFile as c, planArchive as i, memberOf as l, exportWarc as n, writeArchive as o, outputOf as r, TalliedFile as s, cdxjLine as t, writeZip as u };
|
|
727
|
+
|
|
728
|
+
//# sourceMappingURL=warc-VIPscWxY.mjs.map
|