@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/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
+ }