@seostudio/audit-engine 0.0.0-stage → 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 +21 -0
- package/README.md +57 -2
- package/dist/amp-validator.generated-HCTPRNDJ.js +4 -0
- package/dist/chunk-6QPF6O7D.js +197 -0
- package/dist/chunk-7SGWGESF.js +1753 -0
- package/dist/chunk-J4ZNQFJO.js +733 -0
- package/dist/chunk-XTLRDZOD.js +10238 -0
- package/dist/en-gb.generated-SZLUKHIP.js +7 -0
- package/dist/en-us.generated-H6EP75K6.js +7 -0
- package/dist/es.generated-SYEDFESZ.js +7 -0
- package/dist/facts.d.ts +1165 -0
- package/dist/facts.js +1 -0
- package/dist/fr.generated-CQZWZDYQ.js +7 -0
- package/dist/host-gate.d.ts +68 -0
- package/dist/host-gate.js +1 -0
- package/dist/index.d.ts +269 -0
- package/dist/index.js +9314 -0
- package/dist/nl.generated-MW64BR3F.js +7 -0
- package/dist/reading.d.ts +517 -0
- package/dist/reading.js +3 -0
- package/dist/resource-check.d.ts +131 -0
- package/dist/resource-check.js +3 -0
- package/dist/types.d.ts +370 -0
- package/dist/types.js +1 -0
- package/package.json +69 -5
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
import { SEO_READING_LIMITS, toFetchUrl, readStreamCapped, GOOGLEBOT_FILE_BYTES, gunzipCapped } from './chunk-7SGWGESF.js';
|
|
2
|
+
import { seoResourceCheckLimits, isInternalHost, hostGateKey, seoCrawlerLimits, isSharedCdnHost } from './chunk-6QPF6O7D.js';
|
|
3
|
+
|
|
4
|
+
// src/document-facts.ts
|
|
5
|
+
var DOCUMENT_PATH = /\.(?:pdf|docx?|xlsx?|pptx?|odt|ods|odp|rtf|epub)$/iu;
|
|
6
|
+
var TITLE_CHARS = 300;
|
|
7
|
+
var XMP_WINDOW = 4e3;
|
|
8
|
+
function isDocumentUrl(url) {
|
|
9
|
+
try {
|
|
10
|
+
return DOCUMENT_PATH.test(new URL(url).pathname);
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function isPdf(url, contentType) {
|
|
16
|
+
const type = (contentType ?? "").toLowerCase();
|
|
17
|
+
if (type.includes("application/pdf")) return true;
|
|
18
|
+
if (type !== "" && !type.includes("octet-stream")) return false;
|
|
19
|
+
try {
|
|
20
|
+
return /\.pdf$/iu.test(new URL(url).pathname);
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function latin1(bytes) {
|
|
26
|
+
let text = "";
|
|
27
|
+
for (let start = 0; start < bytes.length; start += 8192) {
|
|
28
|
+
text += String.fromCharCode(...bytes.subarray(start, start + 8192));
|
|
29
|
+
}
|
|
30
|
+
return text;
|
|
31
|
+
}
|
|
32
|
+
function pdfText(raw) {
|
|
33
|
+
if (raw.startsWith("\xFE\xFF")) {
|
|
34
|
+
let text = "";
|
|
35
|
+
for (let index = 2; index + 1 < raw.length; index += 2) {
|
|
36
|
+
text += String.fromCharCode(raw.charCodeAt(index) << 8 | raw.charCodeAt(index + 1));
|
|
37
|
+
}
|
|
38
|
+
return text;
|
|
39
|
+
}
|
|
40
|
+
return raw;
|
|
41
|
+
}
|
|
42
|
+
function literalString(text, start) {
|
|
43
|
+
let depth = 1;
|
|
44
|
+
let out = "";
|
|
45
|
+
for (let index = start; index < text.length && out.length <= TITLE_CHARS * 4; index += 1) {
|
|
46
|
+
const char = text[index] ?? "";
|
|
47
|
+
if (char === "\\") {
|
|
48
|
+
const next = text[index + 1] ?? "";
|
|
49
|
+
index += 1;
|
|
50
|
+
if (/[0-7]/u.test(next)) {
|
|
51
|
+
const octal = /^[0-7]{1,3}/u.exec(text.slice(index, index + 3))?.[0] ?? next;
|
|
52
|
+
out += String.fromCharCode(Number.parseInt(octal, 8));
|
|
53
|
+
index += octal.length - 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const escapes = { n: "\n", r: "\r", t: " ", b: "\b", f: "\f" };
|
|
57
|
+
out += escapes[next] ?? (next === "\r" || next === "\n" ? "" : next);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (char === "(") depth += 1;
|
|
61
|
+
if (char === ")") {
|
|
62
|
+
depth -= 1;
|
|
63
|
+
if (depth === 0) return out;
|
|
64
|
+
}
|
|
65
|
+
out += char;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
function keptTitle(value) {
|
|
70
|
+
const folded = value.replace(new RegExp("\\p{Cc}+", "gu"), " ").replace(/\s+/gu, " ").trim();
|
|
71
|
+
return folded === "" ? null : folded.slice(0, TITLE_CHARS);
|
|
72
|
+
}
|
|
73
|
+
function dictionaryTitle(text) {
|
|
74
|
+
for (const match of text.matchAll(/\/Title\s*(\(|<(?!<))/gu)) {
|
|
75
|
+
const at = (match.index ?? 0) + match[0].length;
|
|
76
|
+
if (match[1] === "(") {
|
|
77
|
+
const raw2 = literalString(text, at);
|
|
78
|
+
const title2 = raw2 === null ? null : keptTitle(pdfText(raw2));
|
|
79
|
+
if (title2 !== null) return title2;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const close = text.indexOf(">", at);
|
|
83
|
+
if (close === -1) continue;
|
|
84
|
+
const hex = text.slice(at, close).replace(/\s+/gu, "");
|
|
85
|
+
if (!/^[0-9a-f]*$/iu.test(hex)) continue;
|
|
86
|
+
let raw = "";
|
|
87
|
+
for (let index = 0; index + 1 < hex.length; index += 2) {
|
|
88
|
+
raw += String.fromCharCode(Number.parseInt(hex.slice(index, index + 2), 16));
|
|
89
|
+
}
|
|
90
|
+
const title = keptTitle(pdfText(raw));
|
|
91
|
+
if (title !== null) return title;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
function xmpTitle(text) {
|
|
96
|
+
const open = text.indexOf("<dc:title>");
|
|
97
|
+
if (open === -1) return null;
|
|
98
|
+
const window = text.slice(open, open + XMP_WINDOW);
|
|
99
|
+
const item = window.indexOf("<rdf:li");
|
|
100
|
+
const start = item === -1 ? -1 : window.indexOf(">", item);
|
|
101
|
+
const end = start === -1 ? -1 : window.indexOf("</rdf:li>", start);
|
|
102
|
+
if (end === -1) return null;
|
|
103
|
+
const raw = window.slice(start + 1, end);
|
|
104
|
+
let decoded = raw;
|
|
105
|
+
try {
|
|
106
|
+
decoded = new TextDecoder().decode(Uint8Array.from(raw, (char) => char.charCodeAt(0)));
|
|
107
|
+
} catch {
|
|
108
|
+
decoded = raw;
|
|
109
|
+
}
|
|
110
|
+
return keptTitle(
|
|
111
|
+
decoded.replace(/</gu, "<").replace(/>/gu, ">").replace(/"/gu, '"').replace(/'/gu, "'").replace(/&/gu, "&")
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
var ENCRYPTED = /\/Encrypt\s*(?:\d+\s+\d+\s+R|<<)/u;
|
|
115
|
+
function pdfTitleOf(chunks) {
|
|
116
|
+
const text = chunks.map(latin1).join("\n");
|
|
117
|
+
if (!text.startsWith("%PDF-")) return null;
|
|
118
|
+
if (ENCRYPTED.test(text)) return xmpTitle(text);
|
|
119
|
+
const info = [...text.matchAll(/\/Info\s+(\d+)\s+(\d+)\s+R/gu)].at(-1);
|
|
120
|
+
let infoRead = info === void 0;
|
|
121
|
+
if (info !== void 0) {
|
|
122
|
+
const header = new RegExp(
|
|
123
|
+
`(?:^|[^0-9])${info[1] ?? ""}\\s+${info[2] ?? ""}\\s+obj\\b`,
|
|
124
|
+
"u"
|
|
125
|
+
).exec(text);
|
|
126
|
+
if (header !== null) {
|
|
127
|
+
const start = header.index + header[0].length;
|
|
128
|
+
const end = text.indexOf("endobj", start);
|
|
129
|
+
const body = text.slice(start, end === -1 ? void 0 : end);
|
|
130
|
+
const title = dictionaryTitle(body);
|
|
131
|
+
if (title !== null) return title;
|
|
132
|
+
infoRead = end !== -1;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const xmp = xmpTitle(text);
|
|
136
|
+
if (xmp !== null) return xmp;
|
|
137
|
+
return infoRead ? "" : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/image-size.ts
|
|
141
|
+
function u16be(bytes, at) {
|
|
142
|
+
return (bytes[at] ?? 0) << 8 | (bytes[at + 1] ?? 0);
|
|
143
|
+
}
|
|
144
|
+
function u16le(bytes, at) {
|
|
145
|
+
return (bytes[at] ?? 0) | (bytes[at + 1] ?? 0) << 8;
|
|
146
|
+
}
|
|
147
|
+
function u32be(bytes, at) {
|
|
148
|
+
return u16be(bytes, at) * 65536 + u16be(bytes, at + 2);
|
|
149
|
+
}
|
|
150
|
+
function u24le(bytes, at) {
|
|
151
|
+
return u16le(bytes, at) + (bytes[at + 2] ?? 0) * 65536;
|
|
152
|
+
}
|
|
153
|
+
function tagAt(bytes, at, tag) {
|
|
154
|
+
for (let index = 0; index < tag.length; index += 1) {
|
|
155
|
+
if (bytes[at + index] !== tag.charCodeAt(index)) return false;
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
function sized(width, height) {
|
|
160
|
+
return width > 0 && height > 0 && width < 1e6 && height < 1e6 ? { width, height } : null;
|
|
161
|
+
}
|
|
162
|
+
function jpegSize(bytes) {
|
|
163
|
+
let at = 2;
|
|
164
|
+
while (at + 9 < bytes.length) {
|
|
165
|
+
if (bytes[at] !== 255) return null;
|
|
166
|
+
const marker = bytes[at + 1] ?? 0;
|
|
167
|
+
if (marker === 255) {
|
|
168
|
+
at += 1;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (marker === 216 || marker >= 208 && marker <= 215 || marker === 1) {
|
|
172
|
+
at += 2;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const frame = marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204;
|
|
176
|
+
if (frame) return sized(u16be(bytes, at + 7), u16be(bytes, at + 5));
|
|
177
|
+
at += 2 + u16be(bytes, at + 2);
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
function avifSize(bytes) {
|
|
182
|
+
for (let at = 0; at + 16 <= bytes.length; at += 1) {
|
|
183
|
+
if (tagAt(bytes, at, "ispe")) return sized(u32be(bytes, at + 8), u32be(bytes, at + 12));
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
function imageSizeOf(bytes) {
|
|
188
|
+
if (bytes.length < 10) return null;
|
|
189
|
+
if (bytes[0] === 137 && tagAt(bytes, 1, "PNG") && tagAt(bytes, 12, "IHDR")) {
|
|
190
|
+
return sized(u32be(bytes, 16), u32be(bytes, 20));
|
|
191
|
+
}
|
|
192
|
+
if (tagAt(bytes, 0, "GIF8")) return sized(u16le(bytes, 6), u16le(bytes, 8));
|
|
193
|
+
if (bytes[0] === 255 && bytes[1] === 216) return jpegSize(bytes);
|
|
194
|
+
if (tagAt(bytes, 0, "RIFF") && tagAt(bytes, 8, "WEBP")) {
|
|
195
|
+
if (tagAt(bytes, 12, "VP8 ")) {
|
|
196
|
+
return sized(u16le(bytes, 26) & 16383, u16le(bytes, 28) & 16383);
|
|
197
|
+
}
|
|
198
|
+
if (tagAt(bytes, 12, "VP8L")) {
|
|
199
|
+
const b0 = bytes[21] ?? 0;
|
|
200
|
+
const b1 = bytes[22] ?? 0;
|
|
201
|
+
const b2 = bytes[23] ?? 0;
|
|
202
|
+
const b3 = bytes[24] ?? 0;
|
|
203
|
+
return sized(
|
|
204
|
+
1 + ((b1 & 63) << 8 | b0),
|
|
205
|
+
1 + ((b3 & 15) << 10 | b2 << 2 | b1 >> 6)
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (tagAt(bytes, 12, "VP8X")) return sized(1 + u24le(bytes, 24), 1 + u24le(bytes, 27));
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
if (tagAt(bytes, 4, "ftyp")) return avifSize(bytes);
|
|
212
|
+
if (tagAt(bytes, 0, "BM") && bytes.length >= 26) {
|
|
213
|
+
const width = u16le(bytes, 18) + u16le(bytes, 20) * 65536;
|
|
214
|
+
const raw = u16le(bytes, 22) + u16le(bytes, 24) * 65536;
|
|
215
|
+
const height = raw > 2147483647 ? 4294967296 - raw : raw;
|
|
216
|
+
return sized(width, height);
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
var GIF_LOOP_EXTENSIONS = ["NETSCAPE2.0", "ANIMEXTS1.0"];
|
|
221
|
+
function afterGifSubBlocks(bytes, at) {
|
|
222
|
+
let offset = at;
|
|
223
|
+
while (offset < bytes.length) {
|
|
224
|
+
const size = bytes[offset] ?? 0;
|
|
225
|
+
offset += 1;
|
|
226
|
+
if (size === 0) return offset;
|
|
227
|
+
offset += size;
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
function gifAnimated(bytes) {
|
|
232
|
+
if (!tagAt(bytes, 0, "GIF8") || bytes.length < 13) return false;
|
|
233
|
+
const flags = bytes[10] ?? 0;
|
|
234
|
+
let offset = 13 + ((flags & 128) === 0 ? 0 : 3 * 2 ** ((flags & 7) + 1));
|
|
235
|
+
let frames = 0;
|
|
236
|
+
while (offset < bytes.length) {
|
|
237
|
+
const block = bytes[offset];
|
|
238
|
+
if (block === 59) return false;
|
|
239
|
+
if (block === 33) {
|
|
240
|
+
const label = bytes[offset + 1];
|
|
241
|
+
if (label === 255 && GIF_LOOP_EXTENSIONS.some((tag) => tagAt(bytes, offset + 3, tag)))
|
|
242
|
+
return true;
|
|
243
|
+
const next2 = afterGifSubBlocks(bytes, offset + 2);
|
|
244
|
+
if (next2 === null) return false;
|
|
245
|
+
offset = next2;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (block !== 44) return false;
|
|
249
|
+
frames += 1;
|
|
250
|
+
if (frames > 1) return true;
|
|
251
|
+
const local = bytes[offset + 9] ?? 0;
|
|
252
|
+
offset += 10 + ((local & 128) === 0 ? 0 : 3 * 2 ** ((local & 7) + 1)) + 1;
|
|
253
|
+
const next = afterGifSubBlocks(bytes, offset);
|
|
254
|
+
if (next === null) return false;
|
|
255
|
+
offset = next;
|
|
256
|
+
}
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/resources.ts
|
|
261
|
+
var SEO_LINK_SPELLINGS_PER_SITE = 200;
|
|
262
|
+
function resourceUrl(value) {
|
|
263
|
+
try {
|
|
264
|
+
const url = new URL(value.trim());
|
|
265
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
266
|
+
url.hash = "";
|
|
267
|
+
return url.href;
|
|
268
|
+
} catch {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
var FAILURE_KINDS = {
|
|
273
|
+
script: "script",
|
|
274
|
+
stylesheet: "stylesheet",
|
|
275
|
+
xhr: "other",
|
|
276
|
+
fetch: "other",
|
|
277
|
+
other: "other"
|
|
278
|
+
};
|
|
279
|
+
function isOutboundLink(kind) {
|
|
280
|
+
return kind === "link";
|
|
281
|
+
}
|
|
282
|
+
var OUTBOUND_REFUSALS = /* @__PURE__ */ new Set([401, 403, 407, 408, 429, 451]);
|
|
283
|
+
function outboundFailure(check) {
|
|
284
|
+
if (check.error === "dns_not_found") return "gone";
|
|
285
|
+
if (check.error !== null || check.finalStatus === null) return null;
|
|
286
|
+
const status = check.finalStatus;
|
|
287
|
+
if (status >= 400 && status < 500) return OUTBOUND_REFUSALS.has(status) ? null : "gone";
|
|
288
|
+
if (status >= 500 && status < 600 && status !== 503) return "server";
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
function outboundBroken(check) {
|
|
292
|
+
const failure = outboundFailure(check);
|
|
293
|
+
if (failure === null || check.failCount < 2) return false;
|
|
294
|
+
if (failure === "gone") return true;
|
|
295
|
+
if (check.failingSince === null || check.checkedAt === null) return false;
|
|
296
|
+
return check.failingSince.toISOString().slice(0, 10) !== check.checkedAt.toISOString().slice(0, 10);
|
|
297
|
+
}
|
|
298
|
+
function resourceKey(url, kind) {
|
|
299
|
+
return isOutboundLink(kind) || kind === "spelling" ? `${kind}|${url}` : url;
|
|
300
|
+
}
|
|
301
|
+
function linkSpellingToCheck(spelled, fetched) {
|
|
302
|
+
try {
|
|
303
|
+
const link = new URL(spelled);
|
|
304
|
+
const page = new URL(fetched);
|
|
305
|
+
if (link.protocol !== "http:" && link.protocol !== "https:") return false;
|
|
306
|
+
return link.host.toLowerCase() === page.host.toLowerCase() && link.pathname !== page.pathname;
|
|
307
|
+
} catch {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
function pageResourceRefs(scope, page, rendered) {
|
|
312
|
+
const seen = /* @__PURE__ */ new Set();
|
|
313
|
+
const loads = [];
|
|
314
|
+
const documents = [];
|
|
315
|
+
const links = [];
|
|
316
|
+
const add = (into, url, kind, max) => {
|
|
317
|
+
if (url === null || url.length > SEO_READING_LIMITS.textChars || into.length >= max) return;
|
|
318
|
+
const key = resourceKey(url, kind);
|
|
319
|
+
if (seen.has(key)) return;
|
|
320
|
+
seen.add(key);
|
|
321
|
+
into.push({ url, kind });
|
|
322
|
+
};
|
|
323
|
+
const { resourcesPerPage, outboundLinksPerPage, documentsPerPage } = seoResourceCheckLimits;
|
|
324
|
+
for (const resource of page.markup?.resources ?? []) {
|
|
325
|
+
add(loads, resourceUrl(resource.url), resource.kind, resourcesPerPage);
|
|
326
|
+
}
|
|
327
|
+
for (const failure of rendered?.failedResources ?? []) {
|
|
328
|
+
add(loads, resourceUrl(failure.url), FAILURE_KINDS[failure.type], resourcesPerPage);
|
|
329
|
+
}
|
|
330
|
+
for (const link of page.links) {
|
|
331
|
+
let host;
|
|
332
|
+
try {
|
|
333
|
+
host = new URL(link.href).hostname.toLowerCase();
|
|
334
|
+
} catch {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const url = resourceUrl(link.href);
|
|
338
|
+
if (url !== null && isDocumentUrl(url)) add(documents, url, "document", documentsPerPage);
|
|
339
|
+
if (isInternalHost(scope, host)) continue;
|
|
340
|
+
add(links, url, "link", outboundLinksPerPage);
|
|
341
|
+
}
|
|
342
|
+
return [...loads, ...documents, ...links];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// src/resource-check.ts
|
|
346
|
+
var SEO_IMAGE_SIZE_MIN_BYTES = 20 * 1024;
|
|
347
|
+
var IMAGE_HEADER_BYTES = 32 * 1024;
|
|
348
|
+
var SEO_COMPRESS_MIN_BYTES = 4 * 1024;
|
|
349
|
+
var DECODE_MIN_BYTES = 16 * 1024;
|
|
350
|
+
var COMPRESSIBLE = /^(?:text\/|application\/(?:[\w.+-]*\+)?(?:javascript|ecmascript|json|xml)|image\/svg\+xml)/i;
|
|
351
|
+
var MEDIA_KINDS = /* @__PURE__ */ new Set([
|
|
352
|
+
"image",
|
|
353
|
+
"og_image",
|
|
354
|
+
"icon",
|
|
355
|
+
"font",
|
|
356
|
+
"media"
|
|
357
|
+
]);
|
|
358
|
+
function seoCompressible(kind, contentType) {
|
|
359
|
+
const type = (contentType ?? "").trim().toLowerCase();
|
|
360
|
+
if (MEDIA_KINDS.has(kind)) return type.startsWith("image/svg+xml");
|
|
361
|
+
return COMPRESSIBLE.test(type);
|
|
362
|
+
}
|
|
363
|
+
var HEADER_CHARS = 200;
|
|
364
|
+
var REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
|
|
365
|
+
var HEAD_REFUSED = /* @__PURE__ */ new Set([400, 403, 405, 501]);
|
|
366
|
+
function failureKind(error) {
|
|
367
|
+
if (error instanceof Error) {
|
|
368
|
+
if (error.name === "BlockedUrlError") {
|
|
369
|
+
const reason = "reason" in error ? error.reason : null;
|
|
370
|
+
if (reason === "not_found") return "dns_not_found";
|
|
371
|
+
if (reason === "dns_unavailable") return "network";
|
|
372
|
+
return "blocked";
|
|
373
|
+
}
|
|
374
|
+
if (error.name === "TimeoutError" || error.name === "AbortError") return "timeout";
|
|
375
|
+
}
|
|
376
|
+
return "network";
|
|
377
|
+
}
|
|
378
|
+
async function follow(url, method, userAgent, fetcher, range = "bytes=0-0", accept = "*/*") {
|
|
379
|
+
const chain = [];
|
|
380
|
+
let current = toFetchUrl(url);
|
|
381
|
+
if (current === null) return { chain, finalUrl: url, response: null, error: "blocked" };
|
|
382
|
+
for (let hop = 0; hop <= seoCrawlerLimits.maxRedirects; hop += 1) {
|
|
383
|
+
let response;
|
|
384
|
+
try {
|
|
385
|
+
response = await fetcher(
|
|
386
|
+
current,
|
|
387
|
+
{
|
|
388
|
+
method,
|
|
389
|
+
headers: {
|
|
390
|
+
"User-Agent": userAgent,
|
|
391
|
+
Accept: accept,
|
|
392
|
+
...method !== "GET" ? {} : range === null ? { "Accept-Encoding": "gzip, deflate, br" } : { Range: range }
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
seoCrawlerLimits.timeoutMs
|
|
396
|
+
);
|
|
397
|
+
} catch (error) {
|
|
398
|
+
return { chain, finalUrl: current, response: null, error: failureKind(error) };
|
|
399
|
+
}
|
|
400
|
+
chain.push({ url: current, status: response.status });
|
|
401
|
+
const location = response.headers.get("location");
|
|
402
|
+
if (!REDIRECT_STATUSES.has(response.status) || location === null) {
|
|
403
|
+
return { chain, finalUrl: current, response, error: null };
|
|
404
|
+
}
|
|
405
|
+
await response.body?.cancel().catch(() => void 0);
|
|
406
|
+
let next;
|
|
407
|
+
try {
|
|
408
|
+
next = toFetchUrl(new URL(location, current).href);
|
|
409
|
+
} catch {
|
|
410
|
+
next = null;
|
|
411
|
+
}
|
|
412
|
+
if (next === null) return { chain, finalUrl: current, response: null, error: "blocked" };
|
|
413
|
+
current = next;
|
|
414
|
+
}
|
|
415
|
+
return { chain, finalUrl: current, response: null, error: "too_many_redirects" };
|
|
416
|
+
}
|
|
417
|
+
function wholeNumber(value) {
|
|
418
|
+
if (value === null || !/^\s*\d+\s*$/.test(value)) return null;
|
|
419
|
+
const parsed = Number.parseInt(value, 10);
|
|
420
|
+
return Number.isSafeInteger(parsed) ? parsed : null;
|
|
421
|
+
}
|
|
422
|
+
function rangeTotal(value) {
|
|
423
|
+
const total = /\/\s*(\d+)\s*$/.exec(value ?? "")?.[1];
|
|
424
|
+
return total === void 0 ? null : wholeNumber(total);
|
|
425
|
+
}
|
|
426
|
+
async function sizeOf(response, method) {
|
|
427
|
+
if (method === "GET" && response.status === 206) {
|
|
428
|
+
await response.body?.cancel().catch(() => void 0);
|
|
429
|
+
return rangeTotal(response.headers.get("content-range"));
|
|
430
|
+
}
|
|
431
|
+
const length = wholeNumber(response.headers.get("content-length"));
|
|
432
|
+
if (method === "HEAD" || length !== null) {
|
|
433
|
+
await response.body?.cancel().catch(() => void 0);
|
|
434
|
+
return length;
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
return (await readStreamCapped(response.body, seoResourceCheckLimits.maxBodyBytes)).sizeBytes;
|
|
438
|
+
} catch {
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
async function readingOf(target, hops, method, started, now, measure) {
|
|
443
|
+
const response = hops.response;
|
|
444
|
+
let sizeBytes = null;
|
|
445
|
+
if (response !== null) {
|
|
446
|
+
if (measure && response.status >= 200 && response.status < 300) {
|
|
447
|
+
sizeBytes = await sizeOf(response, method);
|
|
448
|
+
} else {
|
|
449
|
+
await response.body?.cancel().catch(() => void 0);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return {
|
|
453
|
+
url: target.url,
|
|
454
|
+
kind: target.kind,
|
|
455
|
+
method: method === "HEAD" ? "head" : "get",
|
|
456
|
+
status: hops.chain[0]?.status ?? 0,
|
|
457
|
+
finalStatus: response?.status ?? 0,
|
|
458
|
+
finalUrl: hops.finalUrl.slice(0, SEO_READING_LIMITS.textChars),
|
|
459
|
+
chain: hops.chain.map((hop) => ({
|
|
460
|
+
url: hop.url.slice(0, SEO_READING_LIMITS.textChars),
|
|
461
|
+
status: hop.status
|
|
462
|
+
})),
|
|
463
|
+
contentType: response?.headers.get("content-type")?.slice(0, 200) ?? null,
|
|
464
|
+
sizeBytes,
|
|
465
|
+
delivery: measure && response !== null ? keptHeaders(response.headers) : null,
|
|
466
|
+
responseMs: now().getTime() - started,
|
|
467
|
+
error: hops.error
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
function keptHeaders(headers) {
|
|
471
|
+
const cut = (value) => value === null ? null : value.slice(0, HEADER_CHARS);
|
|
472
|
+
return {
|
|
473
|
+
cacheControl: cut(headers.get("cache-control")),
|
|
474
|
+
expires: cut(headers.get("expires")),
|
|
475
|
+
contentEncoding: cut(headers.get("content-encoding"))
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
async function textEncoding(reading, userAgent, fetcher) {
|
|
479
|
+
if (reading.delivery == null) return void 0;
|
|
480
|
+
if (reading.delivery.contentEncoding !== null || !seoCompressible(reading.kind, reading.contentType)) {
|
|
481
|
+
return void 0;
|
|
482
|
+
}
|
|
483
|
+
if (reading.sizeBytes === null || reading.sizeBytes < SEO_COMPRESS_MIN_BYTES) return void 0;
|
|
484
|
+
if (reading.error !== null || reading.finalStatus < 200 || reading.finalStatus >= 300) {
|
|
485
|
+
return void 0;
|
|
486
|
+
}
|
|
487
|
+
const hops = await follow(reading.finalUrl, "GET", userAgent, fetcher, null);
|
|
488
|
+
const response = hops.response;
|
|
489
|
+
if (response === null) return void 0;
|
|
490
|
+
await response.body?.cancel().catch(() => void 0);
|
|
491
|
+
if (response.status < 200 || response.status >= 300) return void 0;
|
|
492
|
+
return response.headers.get("content-encoding")?.slice(0, HEADER_CHARS) ?? null;
|
|
493
|
+
}
|
|
494
|
+
var IMAGE_ACCEPT = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8";
|
|
495
|
+
function acceptFor(kind) {
|
|
496
|
+
return kind === "image" ? IMAGE_ACCEPT : "*/*";
|
|
497
|
+
}
|
|
498
|
+
async function imageHeader(target, reading, userAgent, fetcher) {
|
|
499
|
+
if (target.kind !== "image" && target.kind !== "og_image") return null;
|
|
500
|
+
if (reading.error !== null || reading.finalStatus < 200 || reading.finalStatus >= 300)
|
|
501
|
+
return null;
|
|
502
|
+
const type = (reading.contentType ?? "").toLowerCase();
|
|
503
|
+
if (!type.startsWith("image/") || type.includes("svg")) return null;
|
|
504
|
+
if (reading.sizeBytes !== null && reading.sizeBytes < SEO_IMAGE_SIZE_MIN_BYTES) return null;
|
|
505
|
+
const hops = await follow(
|
|
506
|
+
reading.finalUrl,
|
|
507
|
+
"GET",
|
|
508
|
+
userAgent,
|
|
509
|
+
fetcher,
|
|
510
|
+
`bytes=0-${String(IMAGE_HEADER_BYTES - 1)}`,
|
|
511
|
+
acceptFor(target.kind)
|
|
512
|
+
);
|
|
513
|
+
const response = hops.response;
|
|
514
|
+
if (response === null) return null;
|
|
515
|
+
if (response.status < 200 || response.status >= 300) {
|
|
516
|
+
await response.body?.cancel().catch(() => void 0);
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
try {
|
|
520
|
+
const { body } = await readStreamCapped(response.body, IMAGE_HEADER_BYTES);
|
|
521
|
+
return { size: imageSizeOf(body), animated: gifAnimated(body) };
|
|
522
|
+
} catch {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
async function checkResource(target, userAgent, deps) {
|
|
527
|
+
if (target.kind === "spelling") return checkLinkSpelling(target, userAgent, deps);
|
|
528
|
+
const answer = await checkResourceAnswer(target, userAgent, deps);
|
|
529
|
+
const encoding = await textEncoding(answer, userAgent, deps.fetch);
|
|
530
|
+
const reading = encoding === void 0 || answer.delivery == null ? answer : { ...answer, delivery: { ...answer.delivery, contentEncoding: encoding } };
|
|
531
|
+
const decoded = await decodedSize(target, answer, userAgent, deps.fetch);
|
|
532
|
+
const measured = decoded === void 0 || reading.delivery == null ? reading : { ...reading, delivery: { ...reading.delivery, decodedBytes: decoded } };
|
|
533
|
+
const header = await imageHeader(target, measured, userAgent, deps.fetch);
|
|
534
|
+
const sizedHeader = header?.size == null ? measured : { ...measured, width: header.size.width, height: header.size.height };
|
|
535
|
+
const sized2 = header?.animated === true && sizedHeader.delivery != null ? { ...sizedHeader, delivery: { ...sizedHeader.delivery, animated: true } } : sizedHeader;
|
|
536
|
+
if (target.kind !== "document") return sized2;
|
|
537
|
+
return { ...sized2, title: await documentTitle(sized2, userAgent, deps.fetch) };
|
|
538
|
+
}
|
|
539
|
+
async function rangedBytes(url, range, max, userAgent, fetcher) {
|
|
540
|
+
const hops = await follow(url, "GET", userAgent, fetcher, range);
|
|
541
|
+
const response = hops.response;
|
|
542
|
+
if (response === null) return null;
|
|
543
|
+
if (response.status < 200 || response.status >= 300) {
|
|
544
|
+
await response.body?.cancel().catch(() => void 0);
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
try {
|
|
548
|
+
const { body } = await readStreamCapped(response.body, max);
|
|
549
|
+
return { bytes: body, ranged: response.status === 206 };
|
|
550
|
+
} catch {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
async function documentTitle(reading, userAgent, fetcher) {
|
|
555
|
+
if (reading.error !== null || reading.finalStatus < 200 || reading.finalStatus >= 300)
|
|
556
|
+
return null;
|
|
557
|
+
if (!isPdf(reading.finalUrl, reading.contentType)) return null;
|
|
558
|
+
const max = seoResourceCheckLimits.documentTitleBytes;
|
|
559
|
+
const head = await rangedBytes(
|
|
560
|
+
reading.finalUrl,
|
|
561
|
+
`bytes=0-${String(max - 1)}`,
|
|
562
|
+
max,
|
|
563
|
+
userAgent,
|
|
564
|
+
fetcher
|
|
565
|
+
);
|
|
566
|
+
if (head === null) return null;
|
|
567
|
+
const whole = reading.sizeBytes !== null && reading.sizeBytes <= max;
|
|
568
|
+
if (!head.ranged || whole) return pdfTitleOf([head.bytes]);
|
|
569
|
+
const tail = await rangedBytes(
|
|
570
|
+
reading.finalUrl,
|
|
571
|
+
`bytes=-${String(max)}`,
|
|
572
|
+
max,
|
|
573
|
+
userAgent,
|
|
574
|
+
fetcher
|
|
575
|
+
);
|
|
576
|
+
return pdfTitleOf(tail === null || !tail.ranged ? [head.bytes] : [head.bytes, tail.bytes]);
|
|
577
|
+
}
|
|
578
|
+
async function decodedSize(target, reading, userAgent, fetcher) {
|
|
579
|
+
if (target.kind !== "script" && target.kind !== "stylesheet") return void 0;
|
|
580
|
+
const encoding = (reading.delivery?.contentEncoding ?? "").trim().toLowerCase();
|
|
581
|
+
if (encoding === "" || encoding === "identity") return void 0;
|
|
582
|
+
if (reading.error !== null || reading.finalStatus < 200 || reading.finalStatus >= 300) {
|
|
583
|
+
return void 0;
|
|
584
|
+
}
|
|
585
|
+
if (reading.sizeBytes !== null && reading.sizeBytes < DECODE_MIN_BYTES) return void 0;
|
|
586
|
+
const hops = await follow(reading.finalUrl, "GET", userAgent, fetcher, null);
|
|
587
|
+
const response = hops.response;
|
|
588
|
+
if (response === null) return void 0;
|
|
589
|
+
if (response.status < 200 || response.status >= 300) {
|
|
590
|
+
await response.body?.cancel().catch(() => void 0);
|
|
591
|
+
return void 0;
|
|
592
|
+
}
|
|
593
|
+
try {
|
|
594
|
+
const read = await readStreamCapped(response.body, GOOGLEBOT_FILE_BYTES + 1);
|
|
595
|
+
const gzip = read.body[0] === 31 && read.body[1] === 139;
|
|
596
|
+
if (!gzip || read.truncated) return read.sizeBytes;
|
|
597
|
+
return (await gunzipCapped(read.body, GOOGLEBOT_FILE_BYTES + 1)).sizeBytes;
|
|
598
|
+
} catch {
|
|
599
|
+
return void 0;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
async function checkLinkSpelling(target, userAgent, deps) {
|
|
603
|
+
const started = deps.now().getTime();
|
|
604
|
+
const url = toFetchUrl(target.url);
|
|
605
|
+
const ask = async (method2) => {
|
|
606
|
+
if (url === null) return { response: null, error: "blocked" };
|
|
607
|
+
try {
|
|
608
|
+
const response2 = await deps.fetch(
|
|
609
|
+
url,
|
|
610
|
+
{
|
|
611
|
+
method: method2,
|
|
612
|
+
headers: {
|
|
613
|
+
"User-Agent": userAgent,
|
|
614
|
+
Accept: "*/*",
|
|
615
|
+
...method2 === "GET" ? { Range: "bytes=0-0" } : {}
|
|
616
|
+
}
|
|
617
|
+
},
|
|
618
|
+
seoCrawlerLimits.timeoutMs
|
|
619
|
+
);
|
|
620
|
+
return { response: response2, error: null };
|
|
621
|
+
} catch (error) {
|
|
622
|
+
return { response: null, error: failureKind(error) };
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
let method = "HEAD";
|
|
626
|
+
let answer = await ask(method);
|
|
627
|
+
const recheck = answer.response?.status ?? 0;
|
|
628
|
+
if (HEAD_REFUSED.has(recheck) || recheck === 404 || recheck === 410) {
|
|
629
|
+
await answer.response?.body?.cancel().catch(() => void 0);
|
|
630
|
+
method = "GET";
|
|
631
|
+
answer = await ask(method);
|
|
632
|
+
}
|
|
633
|
+
const response = answer.response;
|
|
634
|
+
await response?.body?.cancel().catch(() => void 0);
|
|
635
|
+
const status = response?.status ?? 0;
|
|
636
|
+
const location = REDIRECT_STATUSES.has(status) ? response?.headers.get("location") : null;
|
|
637
|
+
let finalUrl = url ?? target.url;
|
|
638
|
+
if (location !== null && location !== void 0) {
|
|
639
|
+
try {
|
|
640
|
+
finalUrl = new URL(location, finalUrl).href;
|
|
641
|
+
} catch {
|
|
642
|
+
finalUrl = url ?? target.url;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return {
|
|
646
|
+
url: target.url,
|
|
647
|
+
kind: target.kind,
|
|
648
|
+
method: method === "HEAD" ? "head" : "get",
|
|
649
|
+
status,
|
|
650
|
+
finalStatus: status,
|
|
651
|
+
finalUrl: finalUrl.slice(0, SEO_READING_LIMITS.textChars),
|
|
652
|
+
chain: response === null ? [] : [{ url: (url ?? target.url).slice(0, SEO_READING_LIMITS.textChars), status }],
|
|
653
|
+
contentType: null,
|
|
654
|
+
sizeBytes: null,
|
|
655
|
+
delivery: null,
|
|
656
|
+
responseMs: deps.now().getTime() - started,
|
|
657
|
+
error: answer.error
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
async function checkResourceAnswer(target, userAgent, deps) {
|
|
661
|
+
const started = deps.now().getTime();
|
|
662
|
+
const measure = !isOutboundLink(target.kind);
|
|
663
|
+
const accept = acceptFor(target.kind);
|
|
664
|
+
const head = await follow(target.url, "HEAD", userAgent, deps.fetch, void 0, accept);
|
|
665
|
+
const finalStatus = head.response?.status ?? 0;
|
|
666
|
+
const refused = head.response !== null && (HEAD_REFUSED.has(finalStatus) || !measure && finalStatus >= 400 && finalStatus !== 429);
|
|
667
|
+
const sizeUnknown = measure && head.response !== null && finalStatus >= 200 && finalStatus < 300 && wholeNumber(head.response.headers.get("content-length")) === null;
|
|
668
|
+
if (!refused && !sizeUnknown) {
|
|
669
|
+
return readingOf(target, head, "HEAD", started, deps.now, measure);
|
|
670
|
+
}
|
|
671
|
+
await head.response?.body?.cancel().catch(() => void 0);
|
|
672
|
+
const get = await follow(target.url, "GET", userAgent, deps.fetch, void 0, accept);
|
|
673
|
+
if (sizeUnknown && get.response === null) {
|
|
674
|
+
return readingOf(target, head, "HEAD", started, deps.now, false);
|
|
675
|
+
}
|
|
676
|
+
return readingOf(target, get, "GET", started, deps.now, measure);
|
|
677
|
+
}
|
|
678
|
+
function hostOf(url) {
|
|
679
|
+
try {
|
|
680
|
+
return new URL(url).host.toLowerCase();
|
|
681
|
+
} catch {
|
|
682
|
+
return url;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
var SEO_RESOURCE_HOSTS_AT_ONCE = 4;
|
|
686
|
+
async function readResources(targets, userAgent, deps, options) {
|
|
687
|
+
const nextAt = /* @__PURE__ */ new Map();
|
|
688
|
+
const siteHost = options.siteHost === void 0 ? null : hostGateKey(options.siteHost);
|
|
689
|
+
const delayOf = (host) => siteHost === null || host === siteHost ? options.delayMs : Math.min(options.delayMs, seoCrawlerLimits.minDelayMs);
|
|
690
|
+
const paced = async (url, init, timeoutMs) => {
|
|
691
|
+
const host = hostGateKey(hostOf(url));
|
|
692
|
+
const delayMs = isSharedCdnHost(host) ? Math.min(delayOf(host), seoResourceCheckLimits.sharedCdnDelayMs) : delayOf(host);
|
|
693
|
+
const now = deps.now().getTime();
|
|
694
|
+
const wait = deps.hostGate !== void 0 ? await deps.hostGate.reserve(host, delayMs, now) : Math.max(0, (nextAt.get(host) ?? now) - now);
|
|
695
|
+
nextAt.set(host, Math.max(nextAt.get(host) ?? 0, now + wait + delayMs));
|
|
696
|
+
if (wait > 0) await deps.sleep(wait);
|
|
697
|
+
try {
|
|
698
|
+
return await deps.fetch(url, init, timeoutMs);
|
|
699
|
+
} finally {
|
|
700
|
+
nextAt.set(host, Math.max(nextAt.get(host) ?? 0, deps.now().getTime() + delayMs));
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
const byHost = /* @__PURE__ */ new Map();
|
|
704
|
+
for (const [index, target] of targets.entries()) {
|
|
705
|
+
const host = hostGateKey(hostOf(target.url));
|
|
706
|
+
const queue = byHost.get(host);
|
|
707
|
+
if (queue === void 0) byHost.set(host, [index]);
|
|
708
|
+
else queue.push(index);
|
|
709
|
+
}
|
|
710
|
+
const queues = [...byHost.values()];
|
|
711
|
+
const readings = [];
|
|
712
|
+
let stopped = false;
|
|
713
|
+
const lane = async () => {
|
|
714
|
+
for (let queue = queues.shift(); queue !== void 0; queue = queues.shift()) {
|
|
715
|
+
for (const index of queue) {
|
|
716
|
+
const target = targets[index];
|
|
717
|
+
if (target === void 0) continue;
|
|
718
|
+
if (stopped || options.stop?.() === true) {
|
|
719
|
+
stopped = true;
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
options.onCheck?.(target.url);
|
|
723
|
+
readings[index] = await checkResource(target, userAgent, { fetch: paced, now: deps.now });
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
await Promise.all(
|
|
728
|
+
Array.from({ length: Math.min(SEO_RESOURCE_HOSTS_AT_ONCE, queues.length) }, lane)
|
|
729
|
+
);
|
|
730
|
+
return readings.filter((reading) => reading !== void 0);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
export { SEO_COMPRESS_MIN_BYTES, SEO_IMAGE_SIZE_MIN_BYTES, SEO_LINK_SPELLINGS_PER_SITE, SEO_RESOURCE_HOSTS_AT_ONCE, isOutboundLink, linkSpellingToCheck, outboundBroken, outboundFailure, pageResourceRefs, readResources, seoCompressible };
|