@nurkamol/seo-audit 1.31.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 +792 -0
- package/action.yml +194 -0
- package/bin/seo-audit.mjs +483 -0
- package/package.json +52 -0
- package/src/agents.mjs +122 -0
- package/src/areas.mjs +135 -0
- package/src/audit.mjs +700 -0
- package/src/baseline.mjs +71 -0
- package/src/causes.mjs +167 -0
- package/src/checks.mjs +1253 -0
- package/src/compare.mjs +100 -0
- package/src/config.mjs +156 -0
- package/src/console.mjs +146 -0
- package/src/dupes.mjs +164 -0
- package/src/graph.mjs +89 -0
- package/src/http.mjs +228 -0
- package/src/options.mjs +77 -0
- package/src/parse.mjs +347 -0
- package/src/prompt.mjs +37 -0
- package/src/psi.mjs +200 -0
- package/src/redirects.mjs +145 -0
- package/src/report.mjs +868 -0
- package/src/robots.mjs +92 -0
- package/src/serve.mjs +81 -0
- package/src/site.mjs +714 -0
- package/src/sitemap.mjs +183 -0
package/src/audit.mjs
ADDED
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
// Orchestration: find the pages, fetch them, run the checks.
|
|
2
|
+
import { Fetcher, mapLimit } from './http.mjs';
|
|
3
|
+
import { parseHtml, parseSitemap } from './parse.mjs';
|
|
4
|
+
import { parseRobots, robotsVerdict } from './robots.mjs';
|
|
5
|
+
import { redirectChecks } from './redirects.mjs';
|
|
6
|
+
import { pageChecks, crossPageChecks, sitemapChecks } from './checks.mjs';
|
|
7
|
+
import { siteChecks } from './site.mjs';
|
|
8
|
+
import { linkGraph } from './graph.mjs';
|
|
9
|
+
import { compareAgents } from './compare.mjs';
|
|
10
|
+
import { searchConsole } from './console.mjs';
|
|
11
|
+
import { applyIgnores, expectationChecks, matchGlob } from './config.mjs';
|
|
12
|
+
import { psiChecks, psiTargets, estimateSeconds } from './psi.mjs';
|
|
13
|
+
import { sectionOf } from './causes.mjs';
|
|
14
|
+
import { rebuild, changedSince } from './sitemap.mjs';
|
|
15
|
+
|
|
16
|
+
/** Sitemap URLs, following a sitemap index one level down.
|
|
17
|
+
*
|
|
18
|
+
* robots.txt is asked first, because that is where a site *declares* its
|
|
19
|
+
* sitemap and guessing filenames only works for the conventions you thought
|
|
20
|
+
* of — Yoast writes `/sitemap_index.xml`, Astro writes `/sitemap-index.xml`,
|
|
21
|
+
* and both are wrong to assume. */
|
|
22
|
+
async function discover(origin, fetcher, explicit) {
|
|
23
|
+
const tried = [];
|
|
24
|
+
// A 429 is the server saying "ask later", which is not the same as "there is
|
|
25
|
+
// no sitemap here". Reporting absence from a refusal to answer is a false
|
|
26
|
+
// positive, and the caller needs to know the difference.
|
|
27
|
+
let rateLimited = false;
|
|
28
|
+
let candidates = [explicit];
|
|
29
|
+
|
|
30
|
+
if (!explicit) {
|
|
31
|
+
const robots = await fetcher.get(new URL('/robots.txt', origin).toString());
|
|
32
|
+
const declared = robots.ok
|
|
33
|
+
? [...robots.body.matchAll(/^\s*sitemap:\s*(\S+)\s*$/gim)].map((m) => m[1])
|
|
34
|
+
: [];
|
|
35
|
+
candidates = [
|
|
36
|
+
...declared,
|
|
37
|
+
new URL('/sitemap-index.xml', origin).toString(),
|
|
38
|
+
new URL('/sitemap_index.xml', origin).toString(),
|
|
39
|
+
new URL('/sitemap.xml', origin).toString(),
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const candidate of candidates) {
|
|
44
|
+
// A sitemap may itself redirect (http→https, or /sitemap.xml → the index).
|
|
45
|
+
const res = (await fetcher.chain(candidate)).final;
|
|
46
|
+
tried.push(`${candidate} → ${res.error ?? res.status}`);
|
|
47
|
+
if (res.status === 429) rateLimited = true;
|
|
48
|
+
if (!res.ok || !/<(urlset|sitemapindex)/i.test(res.body)) continue;
|
|
49
|
+
|
|
50
|
+
const { urls, sitemaps, entries } = parseSitemap(res.body);
|
|
51
|
+
// Per-file, because the 50,000-URL and 50MB limits are per sitemap file
|
|
52
|
+
// rather than per site — a flattened total would report the wrong thing.
|
|
53
|
+
// `locs` as well as the count, because a URL listed in two files of one
|
|
54
|
+
// index cannot be seen from a flattened total.
|
|
55
|
+
const stat = (url, body, locs) => ({ url, urls: locs.length, bytes: Buffer.byteLength(body), locs });
|
|
56
|
+
|
|
57
|
+
if (urls.length) {
|
|
58
|
+
return {
|
|
59
|
+
urls,
|
|
60
|
+
entries,
|
|
61
|
+
files: [stat(candidate, res.body, urls)],
|
|
62
|
+
source: candidate,
|
|
63
|
+
tried,
|
|
64
|
+
rateLimited,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const nested = await mapLimit(sitemaps, 4, async (child) => {
|
|
69
|
+
const sub = (await fetcher.chain(child)).final;
|
|
70
|
+
if (!sub.ok) return { urls: [], entries: [], files: [] };
|
|
71
|
+
const parsed = parseSitemap(sub.body);
|
|
72
|
+
return { ...parsed, files: [stat(child, sub.body, parsed.urls)] };
|
|
73
|
+
});
|
|
74
|
+
const all = nested.flatMap((n) => n.urls);
|
|
75
|
+
if (all.length) {
|
|
76
|
+
return {
|
|
77
|
+
urls: all,
|
|
78
|
+
entries: nested.flatMap((n) => n.entries),
|
|
79
|
+
files: nested.flatMap((n) => n.files),
|
|
80
|
+
source: candidate,
|
|
81
|
+
tried,
|
|
82
|
+
rateLimited,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { urls: [], entries: [], files: [], source: null, tried, rateLimited };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Files that are linked from pages but are not pages. Fetching a 40MB video to
|
|
90
|
+
// discover it has no <title> wastes the crawl budget on a site that, by
|
|
91
|
+
// definition, has no sitemap telling us where the pages actually are.
|
|
92
|
+
const NOT_A_PAGE =
|
|
93
|
+
/\.(jpe?g|png|gif|webp|avif|svg|ico|pdf|zip|gz|mp4|webm|mp3|wav|woff2?|ttf|eot|css|js|json|xml|txt|csv|docx?|xlsx?)($|\?)/i;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Breadth-first from the homepage, following internal links.
|
|
97
|
+
*
|
|
98
|
+
* Only used when no sitemap exists. Ordinarily that stopped the tool dead,
|
|
99
|
+
* which meant the sites least likely to have been looked after were the ones it
|
|
100
|
+
* refused to look at.
|
|
101
|
+
*
|
|
102
|
+
* robots.txt is obeyed. A crawler that ignores it is rude, and here it would
|
|
103
|
+
* also spend the budget on exactly the pages nobody wants indexed.
|
|
104
|
+
*/
|
|
105
|
+
async function crawlByLinks(origin, fetcher, { limit, concurrency, robotsGroups, onProgress }) {
|
|
106
|
+
const start = new URL('/', origin).toString();
|
|
107
|
+
const queued = new Set([start]);
|
|
108
|
+
const visited = new Set();
|
|
109
|
+
let frontier = [start];
|
|
110
|
+
const pages = [];
|
|
111
|
+
|
|
112
|
+
while (frontier.length && pages.length < limit) {
|
|
113
|
+
const batch = frontier.slice(0, limit - pages.length);
|
|
114
|
+
frontier = [];
|
|
115
|
+
|
|
116
|
+
// Redirects are followed here, unlike everywhere else in this tool. A link
|
|
117
|
+
// crawl has to land on the page a visitor would land on: www.mozilla.org/
|
|
118
|
+
// answers 302 to /en-US/, and reading only the first hop finds a redirect
|
|
119
|
+
// with no links in it and concludes the site has one page.
|
|
120
|
+
const fetched = await mapLimit(batch, concurrency, async (pageUrl) => {
|
|
121
|
+
const { final } = await fetcher.chain(pageUrl);
|
|
122
|
+
const isHtml = /text\/html/i.test(final.headers.get('content-type') ?? '');
|
|
123
|
+
onProgress?.({ phase: 'crawl', status: final.status, ms: final.ms, url: final.url });
|
|
124
|
+
return {
|
|
125
|
+
url: final.url,
|
|
126
|
+
res: final,
|
|
127
|
+
html: final.body,
|
|
128
|
+
doc: final.ok && isHtml ? parseHtml(final.body, final.url) : null,
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
for (const page of fetched) {
|
|
133
|
+
// Two aliases redirecting to one page are one page.
|
|
134
|
+
const key = page.url.replace(/\/$/, '');
|
|
135
|
+
if (visited.has(key)) continue;
|
|
136
|
+
// A redirect that leaves the site is somebody else's page.
|
|
137
|
+
if (!page.url.startsWith(origin)) continue;
|
|
138
|
+
visited.add(key);
|
|
139
|
+
pages.push(page);
|
|
140
|
+
|
|
141
|
+
for (const href of page.doc?.links.internal ?? []) {
|
|
142
|
+
const clean = href.split('#')[0];
|
|
143
|
+
if (queued.has(clean) || visited.has(clean.replace(/\/$/, '')) || NOT_A_PAGE.test(clean)) continue;
|
|
144
|
+
try {
|
|
145
|
+
if (!robotsVerdict(robotsGroups, new URL(clean).pathname).allowed) continue;
|
|
146
|
+
} catch {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
queued.add(clean);
|
|
150
|
+
frontier.push(clean);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Everything reachable that the budget did not reach.
|
|
156
|
+
return { pages, remaining: frontier.length };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {string} target site origin, or a sitemap URL
|
|
161
|
+
* @param {{limit?: number, concurrency?: number, sitemap?: string}} opts
|
|
162
|
+
*/
|
|
163
|
+
export async function audit(target, opts = {}) {
|
|
164
|
+
const started = Date.now();
|
|
165
|
+
const fetcher = new Fetcher({ concurrency: opts.concurrency ?? 6, userAgent: opts.userAgent });
|
|
166
|
+
|
|
167
|
+
const url = new URL(target);
|
|
168
|
+
const findings = [];
|
|
169
|
+
|
|
170
|
+
// Which host actually serves the site. Everything once-per-domain —
|
|
171
|
+
// robots.txt, llms.txt, the security headers — is only meaningful on the
|
|
172
|
+
// host that answers, and a crawler reads them there: RFC 9309 asks for at
|
|
173
|
+
// least five redirects to be followed for robots.txt, and Google follows
|
|
174
|
+
// them.
|
|
175
|
+
//
|
|
176
|
+
// Audited from the bare domain of a site that lives at www, this used to
|
|
177
|
+
// read all three off a 301. A store with a good robots.txt — agent
|
|
178
|
+
// instructions, a UCP endpoint, the lot — was reported as having none, its
|
|
179
|
+
// llms.txt was looked for on a host that does not serve it, and its
|
|
180
|
+
// Referrer-Policy verdict came from a redirect's headers. Three findings,
|
|
181
|
+
// none of them true, on any site that lives at www.
|
|
182
|
+
let origin = url.origin;
|
|
183
|
+
const landing = await fetcher.chain(`${url.origin}/`);
|
|
184
|
+
if (landing.final.ok) {
|
|
185
|
+
const settled = new URL(landing.final.url).origin;
|
|
186
|
+
if (settled !== origin) {
|
|
187
|
+
findings.push({
|
|
188
|
+
level: 'info',
|
|
189
|
+
id: 'origin-redirected',
|
|
190
|
+
title: 'Audited the host this one redirects to',
|
|
191
|
+
detail:
|
|
192
|
+
`${origin}/ answers ${landing.hops[0]?.status ?? 301} and the chain ends at ${settled}/, so ` +
|
|
193
|
+
'that is where the pages, robots.txt, llms.txt and the response headers were read. Reading ' +
|
|
194
|
+
'them off the redirect instead is how a site with a perfectly good robots.txt gets reported ' +
|
|
195
|
+
'as having none.',
|
|
196
|
+
url: origin,
|
|
197
|
+
});
|
|
198
|
+
opts.onNote?.(`${origin} redirects to ${settled} — auditing there`);
|
|
199
|
+
origin = settled;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Give a rolling deploy time to reach every edge before judging it.
|
|
204
|
+
if (opts.settle) {
|
|
205
|
+
const settled = await fetcher.settle(origin + '/', opts.settle);
|
|
206
|
+
if (!settled && opts.onNote) {
|
|
207
|
+
opts.onNote(`still serving inconsistent HTML after ${opts.settle}s — crawling anyway`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const { urls, entries, files, source, tried, rateLimited: sitemapRateLimited } = await discover(
|
|
211
|
+
origin,
|
|
212
|
+
fetcher,
|
|
213
|
+
opts.sitemap ?? (/\.xml$/i.test(url.pathname) ? target : null),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const limit = opts.limit ?? 200;
|
|
217
|
+
const concurrency = opts.concurrency ?? 6;
|
|
218
|
+
const onProgress = opts.onProgress;
|
|
219
|
+
|
|
220
|
+
if (source) onProgress?.({ phase: 'sitemap', url: source, detail: `${urls.length} URLs` });
|
|
221
|
+
|
|
222
|
+
// A host that never answered is not a sitemap problem, and following links
|
|
223
|
+
// from a page that does not load would find nothing either.
|
|
224
|
+
if (!urls.length && !fetcher.reachable) {
|
|
225
|
+
findings.push({
|
|
226
|
+
level: 'error',
|
|
227
|
+
id: 'unreachable',
|
|
228
|
+
title: 'The site did not answer a single request',
|
|
229
|
+
detail:
|
|
230
|
+
`Tried: ${tried.join(', ')}. The TLS connection succeeds but no response arrives, which ` +
|
|
231
|
+
'usually means a bot-protection rule is stalling non-browser clients — Cloudflare Bot ' +
|
|
232
|
+
"Fight Mode does exactly this. If it is your site, allow this crawler's user agent, or " +
|
|
233
|
+
'pass --user-agent to present a different one.',
|
|
234
|
+
url: origin,
|
|
235
|
+
});
|
|
236
|
+
return {
|
|
237
|
+
findings,
|
|
238
|
+
meta: {
|
|
239
|
+
origin,
|
|
240
|
+
pages: 0,
|
|
241
|
+
ignored: 0,
|
|
242
|
+
requests: fetcher.count,
|
|
243
|
+
ms: Date.now() - started,
|
|
244
|
+
date: new Date().toISOString().slice(0, 10),
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let pages;
|
|
250
|
+
let truncated = 0;
|
|
251
|
+
|
|
252
|
+
// --- only what the sitemap says changed ---------------------------------
|
|
253
|
+
// A five-thousand-page site audited weekly does not need five thousand
|
|
254
|
+
// requests. Refused rather than approximated when the sitemap's lastmod
|
|
255
|
+
// cannot answer it — see changedSince().
|
|
256
|
+
let considered = urls;
|
|
257
|
+
if (opts.since && urls.length) {
|
|
258
|
+
const changed = changedSince(entries, opts.since);
|
|
259
|
+
if (changed.refused) {
|
|
260
|
+
findings.push({
|
|
261
|
+
level: 'warn',
|
|
262
|
+
id: 'since-not-usable',
|
|
263
|
+
title: 'Could not limit the crawl to what changed',
|
|
264
|
+
detail: `${changed.refused} Every URL was checked instead, so this report is complete.`,
|
|
265
|
+
url: origin,
|
|
266
|
+
});
|
|
267
|
+
} else {
|
|
268
|
+
considered = changed.urls;
|
|
269
|
+
findings.push({
|
|
270
|
+
level: 'info',
|
|
271
|
+
id: 'since',
|
|
272
|
+
title: `${changed.skipped.length} URL(s) were unchanged since ${opts.since}`,
|
|
273
|
+
detail:
|
|
274
|
+
`The sitemap says ${changed.changed.length} page(s) changed on or after ${opts.since}` +
|
|
275
|
+
(changed.unknown.length
|
|
276
|
+
? `, and ${changed.unknown.length} carry no lastmod and were checked anyway — not knowing ` +
|
|
277
|
+
'when a page changed is not evidence that it did not'
|
|
278
|
+
: '') +
|
|
279
|
+
`. Nothing below says anything about the ${changed.skipped.length} that were skipped.`,
|
|
280
|
+
url: origin,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// --- URLs the run was told to leave alone -------------------------------
|
|
286
|
+
// Faceted search, tag archives and paginated listings dominate the crawl
|
|
287
|
+
// budget and the report on a real store, and there was no way to keep them
|
|
288
|
+
// out. Applied before the limit, so excluding is what makes room rather than
|
|
289
|
+
// just moving which pages get cut.
|
|
290
|
+
const patterns = opts.exclude ?? [];
|
|
291
|
+
const excluded = [];
|
|
292
|
+
const wanted = patterns.length
|
|
293
|
+
? considered.filter((url) => {
|
|
294
|
+
const path = (() => { try { return new URL(url).pathname; } catch { return url; } })();
|
|
295
|
+
const hit = patterns.some((pattern) => matchGlob(pattern, path));
|
|
296
|
+
if (hit) excluded.push(url);
|
|
297
|
+
return !hit;
|
|
298
|
+
})
|
|
299
|
+
: considered;
|
|
300
|
+
|
|
301
|
+
if (excluded.length) {
|
|
302
|
+
// Always reported. A crawl that quietly shrank is a report that reads as a
|
|
303
|
+
// clean bill of health for pages nobody looked at.
|
|
304
|
+
findings.push({
|
|
305
|
+
level: 'info',
|
|
306
|
+
id: 'excluded',
|
|
307
|
+
title: `${excluded.length} URL(s) were excluded by --exclude`,
|
|
308
|
+
detail:
|
|
309
|
+
`${patterns.join(', ')} matched ${excluded.length} of the ${considered.length} URLs considered, ` +
|
|
310
|
+
`so ${wanted.length} were left to check. This is a fact about the run, not about the site — ` +
|
|
311
|
+
`nothing below says anything about the excluded pages. First few: ${excluded.slice(0, 3).join(', ')}`,
|
|
312
|
+
url: origin,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const bySitemap = wanted.length > 0;
|
|
317
|
+
|
|
318
|
+
if (bySitemap) {
|
|
319
|
+
const list = wanted.slice(0, limit);
|
|
320
|
+
truncated = wanted.length - list.length;
|
|
321
|
+
pages = await mapLimit(list, concurrency, async (pageUrl) => {
|
|
322
|
+
const res = await fetcher.get(pageUrl);
|
|
323
|
+
const isHtml = /text\/html/i.test(res.headers.get('content-type') ?? '');
|
|
324
|
+
onProgress?.({ phase: 'crawl', status: res.status, ms: res.ms, url: pageUrl });
|
|
325
|
+
return {
|
|
326
|
+
url: pageUrl,
|
|
327
|
+
res,
|
|
328
|
+
html: res.body,
|
|
329
|
+
doc: res.ok && isHtml ? parseHtml(res.body, pageUrl) : null,
|
|
330
|
+
};
|
|
331
|
+
});
|
|
332
|
+
} else {
|
|
333
|
+
// No sitemap, but the site answers. Follow links instead of giving up: the
|
|
334
|
+
// sites least likely to have been looked after were the ones this refused
|
|
335
|
+
// to look at.
|
|
336
|
+
opts.onNote?.('no sitemap — following links from the homepage instead');
|
|
337
|
+
const robotsRes = await fetcher.get(new URL('/robots.txt', origin).toString());
|
|
338
|
+
const robotsGroups = robotsRes.ok ? parseRobots(robotsRes.body) : [];
|
|
339
|
+
|
|
340
|
+
const crawled = await crawlByLinks(origin, fetcher, {
|
|
341
|
+
limit,
|
|
342
|
+
concurrency,
|
|
343
|
+
robotsGroups,
|
|
344
|
+
onProgress,
|
|
345
|
+
});
|
|
346
|
+
pages = crawled.pages;
|
|
347
|
+
truncated = crawled.remaining;
|
|
348
|
+
|
|
349
|
+
// A refusal to answer is not an absence. If the server rate-limited the
|
|
350
|
+
// probe, this run learned nothing about whether a sitemap exists, and
|
|
351
|
+
// saying "No sitemap found" would be a finding about the crawl reported as
|
|
352
|
+
// a finding about the site.
|
|
353
|
+
findings.push(
|
|
354
|
+
sitemapRateLimited
|
|
355
|
+
? {
|
|
356
|
+
level: 'warn',
|
|
357
|
+
id: 'sitemap-not-checked',
|
|
358
|
+
title: 'Whether there is a sitemap is not known',
|
|
359
|
+
detail:
|
|
360
|
+
`Tried: ${tried.join(', ')}. The server answered HTTP 429 — "ask later" — so this run ` +
|
|
361
|
+
'never saw whether a sitemap is there, and followed links from the homepage instead, ' +
|
|
362
|
+
`reaching ${pages.length} page(s). This is a fact about the crawl, not about the site. ` +
|
|
363
|
+
'Run it again with a lower --concurrency, or pass --sitemap <url>.',
|
|
364
|
+
url: origin,
|
|
365
|
+
}
|
|
366
|
+
: {
|
|
367
|
+
level: 'warn',
|
|
368
|
+
id: 'no-sitemap',
|
|
369
|
+
title: 'No sitemap found',
|
|
370
|
+
detail:
|
|
371
|
+
`Tried: ${tried.join(', ')}. This run followed links from the homepage instead, which is what a ` +
|
|
372
|
+
`crawler has to do without one — ${pages.length} pages were reached that way. A sitemap states ` +
|
|
373
|
+
'the pages you want indexed rather than leaving it to be inferred, and carries lastmod. ' +
|
|
374
|
+
'Pass --sitemap <url> if one exists somewhere unusual.',
|
|
375
|
+
url: origin,
|
|
376
|
+
},
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
if (!pages.some((p) => p.res.ok)) {
|
|
380
|
+
findings.push(
|
|
381
|
+
sitemapRateLimited || fetcher.rateLimited > 0
|
|
382
|
+
? {
|
|
383
|
+
level: 'error',
|
|
384
|
+
id: 'crawl-rate-limited',
|
|
385
|
+
title: 'Nothing was read — the server rate limited this run',
|
|
386
|
+
detail:
|
|
387
|
+
'Every request came back HTTP 429, so no page was read and nothing below is a ' +
|
|
388
|
+
'statement about the site. Wait, then run it again with a lower --concurrency. ' +
|
|
389
|
+
'Two runs back to back against the same host will do this on their own.',
|
|
390
|
+
url: origin,
|
|
391
|
+
}
|
|
392
|
+
: {
|
|
393
|
+
level: 'error',
|
|
394
|
+
id: 'nothing-crawlable',
|
|
395
|
+
title: 'Nothing could be crawled',
|
|
396
|
+
detail:
|
|
397
|
+
'No sitemap, and the homepage did not return a page to follow links from. There is nothing ' +
|
|
398
|
+
'here to audit.',
|
|
399
|
+
url: origin,
|
|
400
|
+
},
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
onProgress?.({ phase: 'crawl', detail: `${pages.length} pages in ${((Date.now() - started) / 1000).toFixed(1)}s` });
|
|
406
|
+
|
|
407
|
+
for (const page of pages) findings.push(...pageChecks(page, opts.limits));
|
|
408
|
+
// Click depth is measured from the homepage, and a sitemap need not list it.
|
|
409
|
+
// Fetched here only when the crawl did not already have it, and the fetcher
|
|
410
|
+
// caches, so the site checks below pay nothing for it.
|
|
411
|
+
let home = null;
|
|
412
|
+
if (!pages.some((p) => p.doc && p.res.ok && new URL(p.url).pathname.replace(/\/$/, '') === '')) {
|
|
413
|
+
const { final } = await fetcher.chain(origin);
|
|
414
|
+
if (final.ok && /text\/html/i.test(final.headers.get('content-type') ?? '')) {
|
|
415
|
+
home = { url: final.url, doc: parseHtml(final.body, final.url) };
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// One graph for the whole run: the orphan check and click depth read it, and
|
|
419
|
+
// so does the ordering of the report. How many links point at a page, and how
|
|
420
|
+
// far it is from the homepage, is the difference between a list of problems
|
|
421
|
+
// and a list of work worth doing.
|
|
422
|
+
const graph = linkGraph(pages.filter((p) => p.doc && p.res.ok), home);
|
|
423
|
+
findings.push(...crossPageChecks(pages, { limits: opts.limits, truncated, home, graph }));
|
|
424
|
+
// The same pages, asked for by somebody else. Only when asked for: it doubles
|
|
425
|
+
// the request cost of every page it looks at.
|
|
426
|
+
if (opts.compareAs) {
|
|
427
|
+
findings.push(
|
|
428
|
+
...(await compareAgents(pages, {
|
|
429
|
+
agent: opts.compareAs.ua,
|
|
430
|
+
label: opts.compareAs.label,
|
|
431
|
+
sample: opts.compareSample ?? 10,
|
|
432
|
+
onProgress,
|
|
433
|
+
})),
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
findings.push(...sitemapChecks(entries, source, Date.now(), files));
|
|
437
|
+
findings.push(...expectationChecks(pages, opts.expect));
|
|
438
|
+
onProgress?.({ phase: 'checks', detail: `${findings.length} findings from the pages themselves` });
|
|
439
|
+
findings.push(
|
|
440
|
+
...(await siteChecks(origin, fetcher, pages, { ...opts, sitemapUrls: urls, bySitemap })),
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
// A migration's redirect map, checked against the live site. Only when one
|
|
444
|
+
// is handed over: there is nothing to infer here, and guessing at old URLs
|
|
445
|
+
// would invent findings.
|
|
446
|
+
if (opts.redirectRules?.length) {
|
|
447
|
+
findings.push(
|
|
448
|
+
...(await redirectChecks(opts.redirectRules, fetcher, origin, {
|
|
449
|
+
limit: opts.maxRedirectChecks ?? 200,
|
|
450
|
+
onProgress,
|
|
451
|
+
})),
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Performance, measured by Google rather than guessed at here. Slow and
|
|
456
|
+
// rate-limited, so only on request, and for a named page or a sample of a
|
|
457
|
+
// named section rather than the whole crawl.
|
|
458
|
+
if (opts.psi?.length) {
|
|
459
|
+
const { urls: targets, notes } = psiTargets(opts.psi, pages.map((p) => p.url), {
|
|
460
|
+
origin,
|
|
461
|
+
sample: opts.psiSample,
|
|
462
|
+
});
|
|
463
|
+
findings.push(...notes);
|
|
464
|
+
if (targets.length) {
|
|
465
|
+
opts.onNote?.(
|
|
466
|
+
`measuring ${targets.length} page(s) with PageSpeed Insights — about ` +
|
|
467
|
+
`${Math.ceil(estimateSeconds(targets.length) / 60)} min …`,
|
|
468
|
+
);
|
|
469
|
+
findings.push(...(await psiChecks(targets, { strategy: opts.psiStrategy, onProgress })));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Said once, at the end, because a run that took eight minutes should say
|
|
474
|
+
// why. It is not a finding about the site: it is this tool describing what it
|
|
475
|
+
// had to do to get through, and it is the only place the numbers above can be
|
|
476
|
+
// read as "slower than usual" rather than "something is wrong".
|
|
477
|
+
if (fetcher.rateLimited > 0) {
|
|
478
|
+
findings.push({
|
|
479
|
+
level: 'info',
|
|
480
|
+
id: 'rate-limit-slowed',
|
|
481
|
+
title: 'The crawl was slowed down to get through',
|
|
482
|
+
detail:
|
|
483
|
+
`The server answered HTTP 429 — asking for a slower crawl — ${fetcher.rateLimited} time(s), so ` +
|
|
484
|
+
`requests were paused and the concurrency came down to ${fetcher.concurrency}. This is not a ` +
|
|
485
|
+
'finding about the site — it explains the elapsed time, and any page reported as rate-limited ' +
|
|
486
|
+
'was not read at all. Pass a lower --concurrency to get through cleanly.',
|
|
487
|
+
url: origin,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// What these pages actually do in Google. Opt-in, and the only thing here
|
|
492
|
+
// that needs an account.
|
|
493
|
+
if (opts.searchConsole) {
|
|
494
|
+
findings.push(...(await searchConsole(origin, findings, {
|
|
495
|
+
siteUrl: typeof opts.searchConsole === 'string' ? opts.searchConsole : undefined,
|
|
496
|
+
})));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (truncated > 0) {
|
|
500
|
+
findings.push({
|
|
501
|
+
level: 'info',
|
|
502
|
+
id: 'truncated',
|
|
503
|
+
title: bySitemap
|
|
504
|
+
? `${truncated} pages were not checked`
|
|
505
|
+
: `At least ${truncated} more pages are linked but were not checked`,
|
|
506
|
+
detail: bySitemap
|
|
507
|
+
? `The sitemap lists ${urls.length} URLs and the limit is ${pages.length}. Run it again with ` +
|
|
508
|
+
`--limit ${urls.length} to check them all.`
|
|
509
|
+
: `The crawl stopped at ${pages.length} pages with more still queued. Following links cannot know ` +
|
|
510
|
+
'the total in advance the way a sitemap can, so this is a floor, not a count. Raise it with --limit.',
|
|
511
|
+
url: source ?? origin,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Where a finding sits matters nearly as much as what it is. A thin page
|
|
516
|
+
// Google will index is a problem; the same page carrying noindex, or handing
|
|
517
|
+
// its ranking to a canonical elsewhere, is one nobody needs to act on. Tagged
|
|
518
|
+
// in a single pass at the end rather than threaded through every check, since
|
|
519
|
+
// it is a property of the page rather than of any one thing found on it.
|
|
520
|
+
const notIndexable = new Set();
|
|
521
|
+
for (const page of pages) {
|
|
522
|
+
// A page the server refused to hand over is not a page that refuses to be
|
|
523
|
+
// indexed. Its indexability is unknown, and "not indexable" is an answer.
|
|
524
|
+
if (page.res.status === 429) continue;
|
|
525
|
+
if (!page.res.ok) {
|
|
526
|
+
notIndexable.add(page.url);
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (!page.doc) continue;
|
|
530
|
+
const header = page.res.headers?.get?.('x-robots-tag') ?? '';
|
|
531
|
+
const noindexed = /noindex/i.test(page.doc.robots ?? '') || /noindex/i.test(header);
|
|
532
|
+
const canonical = page.doc.canonical?.[0];
|
|
533
|
+
const defersElsewhere =
|
|
534
|
+
canonical && canonical.replace(/\/$/, '') !== page.url.replace(/\/$/, '');
|
|
535
|
+
if (noindexed || defersElsewhere) notIndexable.add(page.url);
|
|
536
|
+
}
|
|
537
|
+
for (const finding of findings) {
|
|
538
|
+
if (finding.url && notIndexable.has(finding.url)) finding.indexable = false;
|
|
539
|
+
// Absent stays absent: "nothing links here" and "this was never measured"
|
|
540
|
+
// are different answers, and only one of them is about the site.
|
|
541
|
+
const reach = finding.url ? graph.reachOf(finding.url) : null;
|
|
542
|
+
if (reach) finding.reach = reach;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// The sitemap says "index this"; the page says otherwise. Same shape as
|
|
546
|
+
// robots.txt disallowing a sitemap URL, and just as invisible: each file is
|
|
547
|
+
// defensible alone and they only contradict each other when read together.
|
|
548
|
+
//
|
|
549
|
+
// Pages that failed to load are excluded — page-status and sitemap-redirect
|
|
550
|
+
// already report those, and this would say it a second time in worse words.
|
|
551
|
+
if (bySitemap) {
|
|
552
|
+
const listed = new Set(urls.map((u) => u.replace(/\/$/, '')));
|
|
553
|
+
const contradictions = pages.filter(
|
|
554
|
+
(p) => p.res.ok && p.doc && notIndexable.has(p.url) && listed.has(p.url.replace(/\/$/, '')),
|
|
555
|
+
);
|
|
556
|
+
if (contradictions.length) {
|
|
557
|
+
const why = (p) =>
|
|
558
|
+
/noindex/i.test(p.doc.robots ?? '') || /noindex/i.test(p.res.headers?.get?.('x-robots-tag') ?? '')
|
|
559
|
+
? 'noindex'
|
|
560
|
+
: `canonical → ${p.doc.canonical[0]}`;
|
|
561
|
+
findings.push({
|
|
562
|
+
level: 'warn',
|
|
563
|
+
id: 'sitemap-not-indexable',
|
|
564
|
+
title: `${contradictions.length} sitemap URL(s) will not be indexed`,
|
|
565
|
+
detail:
|
|
566
|
+
`${contradictions.slice(0, 3).map((p) => `${p.url} (${why(p)})`).join(', ')}` +
|
|
567
|
+
`${contradictions.length > 3 ? `, and ${contradictions.length - 3} more` : ''}. A sitemap is a ` +
|
|
568
|
+
'list of the pages you want indexed; these ask not to be. Either drop them from the sitemap or ' +
|
|
569
|
+
'drop the directive.',
|
|
570
|
+
url: source,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// What the site has decided to live with is dropped last, so an ignore rule
|
|
576
|
+
// can silence a site-wide check as easily as a per-page one.
|
|
577
|
+
const [kept, ignored] = applyIgnores(findings, opts.ignore);
|
|
578
|
+
|
|
579
|
+
// The sitemap this site should have had. Only when asked for, and built from
|
|
580
|
+
// `kept` rather than every finding, so a page silenced by an ignore rule is
|
|
581
|
+
// silenced here too.
|
|
582
|
+
let sitemap = null;
|
|
583
|
+
if (opts.writeSitemap) {
|
|
584
|
+
// robots.txt is already in the fetcher's cache — siteChecks read it — so
|
|
585
|
+
// this costs nothing, and a sitemap that lists a disallowed URL is a
|
|
586
|
+
// conflict the site does not need.
|
|
587
|
+
const robotsRes = await fetcher.get(new URL('/robots.txt', origin).toString());
|
|
588
|
+
const groups = robotsRes.ok ? parseRobots(robotsRes.body) : [];
|
|
589
|
+
sitemap = rebuild(pages, kept, {
|
|
590
|
+
entries,
|
|
591
|
+
truncated,
|
|
592
|
+
rateLimited: kept.filter((f) => f.id === 'rate-limited').length,
|
|
593
|
+
allowed: (url) => robotsVerdict(groups, new URL(url).pathname).allowed,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return {
|
|
598
|
+
findings: kept,
|
|
599
|
+
...(sitemap ? { sitemap } : {}),
|
|
600
|
+
meta: {
|
|
601
|
+
ignored,
|
|
602
|
+
origin,
|
|
603
|
+
pages: pages.length,
|
|
604
|
+
notIndexable: notIndexable.size,
|
|
605
|
+
requests: fetcher.count,
|
|
606
|
+
ms: Date.now() - started,
|
|
607
|
+
date: new Date().toISOString().slice(0, 10),
|
|
608
|
+
sitemap: source,
|
|
609
|
+
},
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** What a run would do, without doing it.
|
|
614
|
+
*
|
|
615
|
+
* A full crawl of a large site is minutes of somebody's time and hundreds of
|
|
616
|
+
* requests to somebody else's server, and until now there was no way to find
|
|
617
|
+
* out whether it was pointed at the right place until it had finished. This
|
|
618
|
+
* costs a handful of requests: the landing page to settle the host, robots.txt,
|
|
619
|
+
* and whichever sitemap answers.
|
|
620
|
+
*
|
|
621
|
+
* It reports what the crawl would actually do rather than what would be ideal.
|
|
622
|
+
* Robots rules are only consulted when there is no sitemap and links are being
|
|
623
|
+
* followed, so this does not claim otherwise — a preview that describes a
|
|
624
|
+
* different crawl from the one that runs is worse than no preview. */
|
|
625
|
+
export async function preview(target, opts = {}) {
|
|
626
|
+
const started = Date.now();
|
|
627
|
+
const fetcher = new Fetcher({ concurrency: opts.concurrency ?? 6, userAgent: opts.userAgent });
|
|
628
|
+
const asked = new URL(target);
|
|
629
|
+
|
|
630
|
+
let origin = asked.origin;
|
|
631
|
+
let redirected = null;
|
|
632
|
+
const landing = await fetcher.chain(`${asked.origin}/`);
|
|
633
|
+
if (landing.final.ok) {
|
|
634
|
+
const settled = new URL(landing.final.url).origin;
|
|
635
|
+
if (settled !== origin) {
|
|
636
|
+
redirected = { from: origin, to: settled };
|
|
637
|
+
origin = settled;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const { urls, entries, source, tried, rateLimited } = await discover(
|
|
642
|
+
origin,
|
|
643
|
+
fetcher,
|
|
644
|
+
opts.sitemap ?? (asked.pathname.match(/\.xml$/i) ? target : null),
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
const limit = opts.limit ?? 200;
|
|
648
|
+
|
|
649
|
+
// The same two filters the crawl applies, or this would describe a different
|
|
650
|
+
// run from the one it is previewing — which is worse than not previewing.
|
|
651
|
+
let considered = urls;
|
|
652
|
+
let sinceRefused = null;
|
|
653
|
+
let skippedBySince = 0;
|
|
654
|
+
if (opts.since && urls.length) {
|
|
655
|
+
const changed = changedSince(entries, opts.since);
|
|
656
|
+
if (changed.refused) sinceRefused = changed.refused;
|
|
657
|
+
else {
|
|
658
|
+
considered = changed.urls;
|
|
659
|
+
skippedBySince = changed.skipped.length;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const patterns = opts.exclude ?? [];
|
|
663
|
+
const beforeExcluding = considered.length;
|
|
664
|
+
if (patterns.length) {
|
|
665
|
+
considered = considered.filter((url) => {
|
|
666
|
+
const path = (() => { try { return new URL(url).pathname; } catch { return url; } })();
|
|
667
|
+
return !patterns.some((pattern) => matchGlob(pattern, path));
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const sections = new Map();
|
|
672
|
+
for (const url of considered) {
|
|
673
|
+
const section = sectionOf(url);
|
|
674
|
+
sections.set(section, (sections.get(section) ?? 0) + 1);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
return {
|
|
678
|
+
origin,
|
|
679
|
+
redirected,
|
|
680
|
+
reachable: fetcher.reachable,
|
|
681
|
+
rateLimited: Boolean(rateLimited),
|
|
682
|
+
sitemap: source,
|
|
683
|
+
tried,
|
|
684
|
+
listed: urls.length,
|
|
685
|
+
skippedBySince: skippedBySince,
|
|
686
|
+
sinceRefused,
|
|
687
|
+
excluded: beforeExcluding - considered.length,
|
|
688
|
+
// Without a sitemap the crawl follows links and cannot know in advance how
|
|
689
|
+
// many pages it will find. Saying "up to the limit" is the honest answer.
|
|
690
|
+
wouldCheck: considered.length ? Math.min(considered.length, limit) : null,
|
|
691
|
+
skippedByLimit: considered.length > limit ? considered.length - limit : 0,
|
|
692
|
+
limit,
|
|
693
|
+
// The biggest parts of the site, which is what decides whether the limit is
|
|
694
|
+
// in the right place.
|
|
695
|
+
sections: [...sections].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([path, count]) => ({ path, count })),
|
|
696
|
+
sample: considered.slice(0, 10),
|
|
697
|
+
requests: fetcher.count,
|
|
698
|
+
ms: Date.now() - started,
|
|
699
|
+
};
|
|
700
|
+
}
|