seo-crawl-audit 0.1.2 → 0.7.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/src/compare.js DELETED
@@ -1,205 +0,0 @@
1
- const SEVERITY_ORDER = { error: 0, warning: 1 };
2
-
3
- function hasDirective(value, directive) {
4
- return new RegExp(`(^|[\\s,])${directive}($|[\\s,])`, "i").test(value ?? "");
5
- }
6
-
7
- function addIssue(issues, severity, rule, url, message, before, after) {
8
- issues.push({ severity, rule, url, message, before, after });
9
- }
10
-
11
- export function compareBaselines(baseline, current) {
12
- const issues = [];
13
- const currentPages = new Map(current.pages.map((page) => [page.url, page]));
14
-
15
- if (
16
- baseline.robots.sha256 &&
17
- current.robots.sha256 &&
18
- baseline.robots.sha256 !== current.robots.sha256
19
- ) {
20
- addIssue(
21
- issues,
22
- "warning",
23
- "robots-changed",
24
- current.robots.url,
25
- "robots.txt content changed",
26
- baseline.robots.sha256,
27
- current.robots.sha256,
28
- );
29
- }
30
-
31
- for (const before of baseline.pages) {
32
- const after = currentPages.get(before.url);
33
-
34
- if (!after) {
35
- addIssue(
36
- issues,
37
- "error",
38
- "page-missing",
39
- before.url,
40
- "page was not checked",
41
- before.status,
42
- null,
43
- );
44
- continue;
45
- }
46
-
47
- if (after.blockedByRobots && !before.blockedByRobots) {
48
- addIssue(
49
- issues,
50
- "error",
51
- "robots-blocked",
52
- before.url,
53
- "page is now blocked by robots.txt",
54
- false,
55
- true,
56
- );
57
- continue;
58
- }
59
-
60
- if (after.error && !before.error) {
61
- addIssue(
62
- issues,
63
- "error",
64
- "page-unreachable",
65
- before.url,
66
- `page request failed: ${after.error}`,
67
- before.error,
68
- after.error,
69
- );
70
- continue;
71
- }
72
-
73
- if (
74
- before.status !== null &&
75
- before.status < 400 &&
76
- (after.status === null || after.status >= 400)
77
- ) {
78
- addIssue(
79
- issues,
80
- "error",
81
- "status-regression",
82
- before.url,
83
- `HTTP status regressed from ${before.status} to ${after.status ?? "none"}`,
84
- before.status,
85
- after.status,
86
- );
87
- }
88
-
89
- if (
90
- !hasDirective(before.robots, "noindex") &&
91
- hasDirective(after.robots, "noindex")
92
- ) {
93
- addIssue(
94
- issues,
95
- "error",
96
- "new-noindex",
97
- before.url,
98
- "page now contains a noindex directive",
99
- before.robots,
100
- after.robots,
101
- );
102
- }
103
-
104
- if (before.title && !after.title) {
105
- addIssue(
106
- issues,
107
- "error",
108
- "title-removed",
109
- before.url,
110
- "page title was removed",
111
- before.title,
112
- after.title,
113
- );
114
- } else if (before.title && after.title && before.title !== after.title) {
115
- addIssue(
116
- issues,
117
- "warning",
118
- "title-changed",
119
- before.url,
120
- "page title changed",
121
- before.title,
122
- after.title,
123
- );
124
- }
125
-
126
- if (before.description && !after.description) {
127
- addIssue(
128
- issues,
129
- "warning",
130
- "description-removed",
131
- before.url,
132
- "meta description was removed",
133
- before.description,
134
- after.description,
135
- );
136
- }
137
-
138
- if (before.canonical && !after.canonical) {
139
- addIssue(
140
- issues,
141
- "error",
142
- "canonical-removed",
143
- before.url,
144
- "canonical URL was removed",
145
- before.canonical,
146
- after.canonical,
147
- );
148
- } else if (
149
- before.canonical &&
150
- after.canonical &&
151
- before.canonical !== after.canonical
152
- ) {
153
- const pageOrigin = new URL(before.url).origin;
154
- const canonicalOrigin = new URL(after.canonical).origin;
155
- addIssue(
156
- issues,
157
- canonicalOrigin === pageOrigin ? "warning" : "error",
158
- "canonical-changed",
159
- before.url,
160
- "canonical URL changed",
161
- before.canonical,
162
- after.canonical,
163
- );
164
- }
165
-
166
- if ((before.h1Count ?? 0) > 0 && (after.h1Count ?? 0) === 0) {
167
- addIssue(
168
- issues,
169
- "warning",
170
- "h1-removed",
171
- before.url,
172
- "all H1 headings were removed",
173
- before.h1Count,
174
- after.h1Count,
175
- );
176
- }
177
-
178
- if (
179
- before.finalUrl &&
180
- after.finalUrl &&
181
- before.finalUrl !== after.finalUrl
182
- ) {
183
- const pageOrigin = new URL(before.url).origin;
184
- const redirectOrigin = new URL(after.finalUrl).origin;
185
- addIssue(
186
- issues,
187
- redirectOrigin === pageOrigin ? "warning" : "error",
188
- "redirect-changed",
189
- before.url,
190
- "final redirect URL changed",
191
- before.finalUrl,
192
- after.finalUrl,
193
- );
194
- }
195
- }
196
-
197
- issues.sort(
198
- (left, right) =>
199
- SEVERITY_ORDER[left.severity] - SEVERITY_ORDER[right.severity] ||
200
- left.url.localeCompare(right.url) ||
201
- left.rule.localeCompare(right.rule),
202
- );
203
-
204
- return issues;
205
- }
package/src/crawler.js DELETED
@@ -1,306 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { extractSeoData } from "./html.js";
3
- import { createRequestGate } from "./request-gate.js";
4
- import { isAllowedByRobots, parseRobots } from "./robots.js";
5
- import { loadSitemapUrls } from "./sitemap.js";
6
- import {
7
- isCrawlableUrl,
8
- isSameOrigin,
9
- normalizeUrl,
10
- } from "./urls.js";
11
-
12
- const DEFAULT_USER_AGENT = "seo-crawl-audit/0.1.2";
13
- const MAX_HTML_BYTES = 5 * 1024 * 1024;
14
- const MAX_ROBOTS_BYTES = 512 * 1024;
15
-
16
- async function readBody(response, maxBytes) {
17
- if (!response.body) {
18
- return "";
19
- }
20
-
21
- const reader = response.body.getReader();
22
- const chunks = [];
23
- let total = 0;
24
-
25
- while (true) {
26
- const { done, value } = await reader.read();
27
- if (done) {
28
- break;
29
- }
30
-
31
- total += value.byteLength;
32
- if (total > maxBytes) {
33
- await reader.cancel();
34
- throw new Error(`response exceeds ${maxBytes} bytes`);
35
- }
36
- chunks.push(Buffer.from(value));
37
- }
38
-
39
- return Buffer.concat(chunks).toString("utf8");
40
- }
41
-
42
- async function loadRobots(startUrl, options) {
43
- const robotsUrl = new URL("/robots.txt", startUrl).href;
44
-
45
- try {
46
- await options.requestGate();
47
- const response = await fetch(robotsUrl, {
48
- headers: {
49
- accept: "text/plain,*/*;q=0.1",
50
- "user-agent": options.userAgent,
51
- },
52
- redirect: "follow",
53
- signal: AbortSignal.timeout(options.timeout),
54
- });
55
- const body = await readBody(response, MAX_ROBOTS_BYTES);
56
-
57
- return {
58
- url: robotsUrl,
59
- status: response.status,
60
- body,
61
- sha256: createHash("sha256").update(body).digest("hex"),
62
- rules: response.ok ? parseRobots(body, options.userAgent) : [],
63
- denyAll: response.status === 401 || response.status === 403,
64
- error: null,
65
- };
66
- } catch (error) {
67
- return {
68
- url: robotsUrl,
69
- status: null,
70
- body: "",
71
- sha256: null,
72
- rules: [],
73
- denyAll: false,
74
- error: error.message,
75
- };
76
- }
77
- }
78
-
79
- async function fetchPage(requestUrl, options) {
80
- if (
81
- options.respectRobots &&
82
- !isAllowedByRobots(requestUrl, options.robots)
83
- ) {
84
- return {
85
- url: requestUrl,
86
- finalUrl: null,
87
- status: null,
88
- contentType: null,
89
- blockedByRobots: true,
90
- error: null,
91
- title: null,
92
- description: null,
93
- canonical: null,
94
- robots: null,
95
- lang: null,
96
- h1Count: 0,
97
- openGraph: { title: null, description: null, image: null },
98
- links: [],
99
- };
100
- }
101
-
102
- try {
103
- await options.requestGate();
104
- const response = await fetch(requestUrl, {
105
- headers: {
106
- accept: "text/html,application/xhtml+xml;q=0.9,*/*;q=0.1",
107
- "user-agent": options.userAgent,
108
- },
109
- redirect: "follow",
110
- signal: AbortSignal.timeout(options.timeout),
111
- });
112
- const contentType = response.headers.get("content-type") ?? "";
113
- const isHtml =
114
- contentType.includes("text/html") ||
115
- contentType.includes("application/xhtml+xml");
116
- const finalUrl = normalizeUrl(response.url, undefined, {
117
- includeQuery: options.includeQuery,
118
- });
119
-
120
- let seo = {
121
- title: null,
122
- description: null,
123
- canonical: null,
124
- robots: null,
125
- lang: null,
126
- h1Count: 0,
127
- openGraph: { title: null, description: null, image: null },
128
- links: [],
129
- };
130
-
131
- if (isHtml) {
132
- const body = await readBody(response, MAX_HTML_BYTES);
133
- seo = extractSeoData(body);
134
- } else {
135
- await response.body?.cancel();
136
- }
137
-
138
- const canonical = seo.canonical
139
- ? normalizeUrl(seo.canonical, finalUrl ?? requestUrl, {
140
- includeQuery: true,
141
- })
142
- : null;
143
- const links = [
144
- ...new Set(
145
- seo.links
146
- .map((link) =>
147
- normalizeUrl(link, finalUrl ?? requestUrl, {
148
- includeQuery: options.includeQuery,
149
- }),
150
- )
151
- .filter(Boolean),
152
- ),
153
- ];
154
-
155
- return {
156
- url: requestUrl,
157
- finalUrl,
158
- status: response.status,
159
- contentType: contentType || null,
160
- blockedByRobots: false,
161
- error: null,
162
- ...seo,
163
- canonical,
164
- links,
165
- };
166
- } catch (error) {
167
- return {
168
- url: requestUrl,
169
- finalUrl: null,
170
- status: null,
171
- contentType: null,
172
- blockedByRobots: false,
173
- error: error.message,
174
- title: null,
175
- description: null,
176
- canonical: null,
177
- robots: null,
178
- lang: null,
179
- h1Count: 0,
180
- openGraph: { title: null, description: null, image: null },
181
- links: [],
182
- };
183
- }
184
- }
185
-
186
- function withDefaults(options = {}) {
187
- const requestDelay = options.requestDelay ?? 100;
188
-
189
- return {
190
- maxPages: options.maxPages ?? 100,
191
- concurrency: options.concurrency ?? 5,
192
- timeout: options.timeout ?? 10_000,
193
- includeQuery: options.includeQuery ?? false,
194
- respectRobots: options.respectRobots ?? true,
195
- sitemap: options.sitemap ?? null,
196
- sitemapData: options.sitemapData ?? null,
197
- userAgent: options.userAgent ?? DEFAULT_USER_AGENT,
198
- requestDelay,
199
- requestGate:
200
- options.requestGate ?? createRequestGate(requestDelay),
201
- };
202
- }
203
-
204
- export async function fetchPages(urls, rawOptions = {}) {
205
- const options = withDefaults(rawOptions);
206
- const robots =
207
- rawOptions.robots ?? (await loadRobots(urls[0], options));
208
- const pages = [];
209
- const cached = new Map(
210
- (rawOptions.cachedPages ?? []).map((page) => [page.url, page]),
211
- );
212
-
213
- for (let index = 0; index < urls.length; index += options.concurrency) {
214
- const batch = urls.slice(index, index + options.concurrency);
215
- const freshUrls = batch.filter((url) => !cached.has(url));
216
- const freshResults = await Promise.all(
217
- freshUrls.map((url) => fetchPage(url, { ...options, robots })),
218
- );
219
- await rawOptions.onBatch?.(freshResults);
220
- const fresh = new Map(freshResults.map((page) => [page.url, page]));
221
- const results = batch.map((url) => cached.get(url) ?? fresh.get(url));
222
- pages.push(...results);
223
- rawOptions.onProgress?.(pages.length, urls.length);
224
- }
225
-
226
- return { pages, robots };
227
- }
228
-
229
- export async function crawlSite(inputUrl, rawOptions = {}) {
230
- const options = withDefaults(rawOptions);
231
- const startUrl = normalizeUrl(inputUrl, undefined, {
232
- includeQuery: options.includeQuery,
233
- });
234
-
235
- if (!startUrl) {
236
- throw new Error(`invalid start URL: ${inputUrl}`);
237
- }
238
-
239
- const origin = new URL(startUrl).origin;
240
- const robots = await loadRobots(startUrl, options);
241
- const sitemap = options.sitemapData ?? (options.sitemap
242
- ? await loadSitemapUrls(options.sitemap, {
243
- ...options,
244
- maxUrls: options.sitemapMaxUrls ?? 50_000,
245
- siteOrigin: origin,
246
- })
247
- : null);
248
- const queue = [
249
- ...new Set([startUrl, ...(sitemap?.urls ?? [])]),
250
- ].slice(0, options.maxPages);
251
- const scheduled = new Set(queue);
252
- const cached = new Map(
253
- (rawOptions.cachedPages ?? []).map((page) => [page.url, page]),
254
- );
255
- const pages = [];
256
- let cursor = 0;
257
- const maxQueueSize = Math.max(options.maxPages * 10, options.maxPages);
258
-
259
- while (cursor < queue.length && pages.length < options.maxPages) {
260
- const remaining = options.maxPages - pages.length;
261
- const batch = queue.slice(
262
- cursor,
263
- cursor + Math.min(options.concurrency, remaining),
264
- );
265
- cursor += batch.length;
266
-
267
- const freshUrls = batch.filter((url) => !cached.has(url));
268
- const freshResults = await Promise.all(
269
- freshUrls.map((url) => fetchPage(url, { ...options, robots })),
270
- );
271
- await rawOptions.onBatch?.(freshResults);
272
- const fresh = new Map(freshResults.map((page) => [page.url, page]));
273
- const results = batch.map((url) => cached.get(url) ?? fresh.get(url));
274
- pages.push(...results);
275
- rawOptions.onProgress?.(pages.length, options.maxPages);
276
-
277
- for (const page of results) {
278
- for (const link of page.links ?? []) {
279
- if (
280
- scheduled.size >= maxQueueSize ||
281
- scheduled.has(link) ||
282
- !isSameOrigin(link, origin) ||
283
- !isCrawlableUrl(link)
284
- ) {
285
- continue;
286
- }
287
- scheduled.add(link);
288
- queue.push(link);
289
- }
290
- }
291
- }
292
-
293
- pages.sort((left, right) => left.url.localeCompare(right.url));
294
-
295
- return {
296
- startUrl,
297
- pages,
298
- robots,
299
- sitemap,
300
- truncated:
301
- cursor < queue.length ||
302
- (sitemap ? sitemap.urls.length > pages.length : false) ||
303
- (sitemap?.truncated ?? false),
304
- options,
305
- };
306
- }