@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/site.mjs ADDED
@@ -0,0 +1,714 @@
1
+ // Whole-site checks: the files and headers that exist once per domain, plus
2
+ // the link graph, which is the thing single-page graders can never see.
3
+ import { connect } from 'node:tls';
4
+ import { mapLimit } from './http.mjs';
5
+ import { parseRobots, robotsVerdict } from './robots.mjs';
6
+ import { parseHtml } from './parse.mjs';
7
+ import { schemaNodes, seriesOf, paginatedCanonical } from './checks.mjs';
8
+
9
+ // Two weeks is enough to renew by hand if the automation has quietly stopped,
10
+ // which is the failure this is for — nobody is short of warning about a
11
+ // certificate they knew was expiring.
12
+ const CERT_WARN_DAYS = 14;
13
+ const DAY = 24 * 60 * 60 * 1000;
14
+
15
+ /** When the certificate expires, or null if that cannot be established.
16
+ *
17
+ * Deliberately its own connection rather than anything read off a fetch: Node
18
+ * does not expose the peer certificate through `fetch`, and this is the whole
19
+ * of the dependency-free way to ask. */
20
+ export function certificateExpiry(hostname, { timeout = 8000 } = {}) {
21
+ return new Promise((resolve) => {
22
+ let socket;
23
+ const done = (value) => {
24
+ socket?.destroy();
25
+ resolve(value);
26
+ };
27
+ try {
28
+ // Validation is switched off deliberately, and only here. An *expired*
29
+ // certificate fails the handshake, so a validating connection cannot read
30
+ // the one fact this function exists to report — the check would go silent
31
+ // in exactly the case it is for. Nothing is sent over this socket and
32
+ // nothing is read from it but the certificate's dates, which are the same
33
+ // ones a browser would show.
34
+ // SNI is not permitted to carry an IP address (RFC 6066), and Node warns
35
+ // about it. An IP has no name to send.
36
+ const isIp = /^[\d.]+$/.test(hostname) || hostname.includes(':');
37
+ socket = connect(
38
+ {
39
+ host: hostname,
40
+ port: 443,
41
+ ...(isIp ? {} : { servername: hostname }),
42
+ timeout,
43
+ rejectUnauthorized: false,
44
+ },
45
+ () => {
46
+ const cert = socket.getPeerCertificate();
47
+ done(cert?.valid_to ? Date.parse(cert.valid_to) : null);
48
+ },
49
+ );
50
+ } catch {
51
+ return resolve(null);
52
+ }
53
+ // A host that is not listening, or not speaking TLS, has nothing to say.
54
+ socket.on('error', () => done(null));
55
+ socket.on('timeout', () => done(null));
56
+ });
57
+ }
58
+
59
+ const f = (level, id, title, detail, url) => ({ level, id, title, detail, url });
60
+
61
+ export async function siteChecks(origin, fetcher, pages, opts = {}) {
62
+ // Every URL the sitemap listed, not only the ones this run crawled — with
63
+ // --limit in play they are not the same set, and treating them as the same
64
+ // reports every uncrawled page as missing from the sitemap.
65
+ const inSitemap = new Set((opts.sitemapUrls ?? []).map((u) => u.replace(/\/$/, '')));
66
+ const out = [];
67
+ const base = new URL(origin);
68
+
69
+ // A file is absent when the server says it is absent. Anything else — a rate
70
+ // limit, a 403 from bot protection, a 5xx — means the answer was not given,
71
+ // and "there is no robots.txt" is an answer. The page checks learned this in
72
+ // 1.15.0 and these did not: a store that answers 429 under load had its
73
+ // llms.txt reported missing while serving it at 200 the moment it was asked
74
+ // again by hand.
75
+ const absent = (res) => res.status === 404 || res.status === 410 || res.status === 0;
76
+
77
+ // --- robots.txt ---------------------------------------------------------
78
+ const robots = await fetcher.get(new URL('/robots.txt', base).toString());
79
+ let blocksAll = false;
80
+ if (!robots.ok && absent(robots)) {
81
+ out.push(f('warn', 'robots-missing', 'No robots.txt',
82
+ `HTTP ${robots.status || robots.error}. Not fatal, but it is where the sitemap is advertised.`,
83
+ robots.url));
84
+ } else if (robots.ok) {
85
+ const groups = parseRobots(robots.body);
86
+
87
+ // Asked of the parser rather than by pattern-matching the file. The old
88
+ // test was "some line says Disallow: / and some line says User-agent: *",
89
+ // which are routinely different groups: gov.uk blocks deepcrawl and
90
+ // python.org blocks HTTrack, and both were reported as blocking the entire
91
+ // site from everyone.
92
+ if (!robotsVerdict(groups, '/').allowed) {
93
+ blocksAll = true;
94
+ out.push(f('error', 'robots-blocks-all', 'robots.txt blocks the whole site',
95
+ 'Disallow: / applies to Googlebot. Nothing will be indexed.', robots.url));
96
+ }
97
+ if (!/sitemap:/i.test(robots.body)) {
98
+ out.push(f('info', 'robots-no-sitemap', 'robots.txt does not list a sitemap',
99
+ 'One line, and every crawler finds the sitemap without guessing.', robots.url));
100
+ }
101
+
102
+ // The site contradicting itself: the sitemap says index this, robots.txt
103
+ // says do not crawl it. Skipped when the whole site is blocked, because
104
+ // that is already reported above and this would restate it once per URL.
105
+ if (!blocksAll) {
106
+ const blocked = [];
107
+ for (const listed of opts.sitemapUrls ?? []) {
108
+ let path;
109
+ try {
110
+ path = new URL(listed).pathname;
111
+ } catch {
112
+ continue;
113
+ }
114
+ const verdict = robotsVerdict(groups, path);
115
+ if (!verdict.allowed) blocked.push({ listed, rule: verdict.rule });
116
+ }
117
+ if (blocked.length) {
118
+ const shown = blocked.slice(0, 3).map((b) => `${b.listed} (Disallow: ${b.rule.path})`).join(', ');
119
+ out.push(f('error', 'robots-blocks-sitemap-url',
120
+ `${blocked.length} sitemap URL(s) are disallowed by robots.txt`,
121
+ `${shown}${blocked.length > 3 ? `, and ${blocked.length - 3} more` : ''}. The sitemap asks Google ` +
122
+ 'to index these and robots.txt forbids fetching them, so they land in the index without a ' +
123
+ 'description, or not at all. One of the two files is wrong.', robots.url));
124
+ }
125
+ }
126
+ }
127
+
128
+ // --- llms.txt -----------------------------------------------------------
129
+ const llms = await fetcher.get(new URL('/llms.txt', base).toString());
130
+ if (absent(llms)) {
131
+ out.push(f('info', 'llms-missing', 'No llms.txt',
132
+ 'The emerging convention for telling AI assistants what a site is and which pages matter.', llms.url));
133
+ }
134
+
135
+ // --- Soft 404s ----------------------------------------------------------
136
+ // A URL that cannot exist has to answer 404. When it answers 200 instead,
137
+ // every typo, every stale inbound link and every crawler guess becomes an
138
+ // indexable page, and the site quietly fills the index with copies of its own
139
+ // error page. Nothing on the site reveals this — you have to ask for
140
+ // something missing, which no visitor and no single-page grader ever does.
141
+ //
142
+ // A fixed path rather than a random one, so the finding is identical between
143
+ // runs and --baseline has something stable to compare.
144
+ // The chain is followed and only the *final* answer judged, because the first
145
+ // hop says almost nothing. Two real behaviours seen in the wild: wikipedia.org
146
+ // answers 301 and then 404, which is correct and must stay silent; vercel.com
147
+ // answers 308 to strip the trailing slash and then 200, which is a soft 404
148
+ // that reading only the first hop would miss entirely.
149
+ const probe = new URL('/seo-audit-probe-404/', base).toString();
150
+ const { hops, final } = await fetcher.chain(probe);
151
+ const servedHtml = /text\/html/i.test(final.headers.get('content-type') ?? '');
152
+
153
+ if (final.status === 200 && servedHtml) {
154
+ // hops already includes the final response, so it is not appended again.
155
+ const route =
156
+ hops.length > 1
157
+ ? `answers ${hops.map((h) => h.status).join(' → ')}, ending at ${final.url}`
158
+ : 'answers 200 directly';
159
+ const landedHome = final.url.replace(/\/$/, '') === base.origin.replace(/\/$/, '');
160
+ // A 200 that says noindex is a deliberate mitigation rather than an
161
+ // oversight: still wrong, because Google wants the status code, but the page
162
+ // will not be indexed and the damage stops there.
163
+ const metaRobots = final.body.match(/<meta[^>]+name=["']robots["'][^>]*>/i)?.[0] ?? '';
164
+ const noindexed =
165
+ /noindex/i.test(metaRobots) || /noindex/i.test(final.headers.get('x-robots-tag') ?? '');
166
+
167
+ if (landedHome) {
168
+ out.push(f('warn', 'soft-404', 'Missing pages end up on the homepage instead of 404ing',
169
+ `${probe} ${route}. Google treats this as a soft 404 regardless, and a visitor who followed a ` +
170
+ 'broken link lands on the homepage with no idea what went wrong.', probe));
171
+ } else if (noindexed) {
172
+ out.push(f('warn', 'soft-404', 'A page that does not exist answers 200, but is noindexed',
173
+ `${probe} ${route}. The noindex keeps it out of the index, but crawlers still spend budget on ` +
174
+ 'every missing URL, and nothing tells a visitor the link is dead.', probe));
175
+ } else {
176
+ out.push(f('error', 'soft-404', 'A page that does not exist answers 200',
177
+ `${probe} ${route}, with an HTML body. Every mistyped or stale URL is a live, indexable page, ` +
178
+ 'so the index fills with copies of the error page. Return a real 404.', probe));
179
+ }
180
+ }
181
+
182
+ // --- Favicon ------------------------------------------------------------
183
+ // Google draws one beside every result a site owns, and shows a default globe
184
+ // where it finds none. It reads the declaration from the home page and
185
+ // accepts three rel values: icon, apple-touch-icon and
186
+ // apple-touch-icon-precomposed.
187
+ //
188
+ // Only two things are reported, and both are facts: a declared icon that is
189
+ // not there, and no declaration with nothing at /favicon.ico either. A site
190
+ // serving one from a path it never declared is working exactly as intended,
191
+ // and guessing otherwise would be inventing a finding.
192
+ const homePage = pages.find((p) => {
193
+ try {
194
+ return p.doc && new URL(p.url).pathname.replace(/\/$/, '') === '';
195
+ } catch {
196
+ return false;
197
+ }
198
+ });
199
+ // The home page is not always in the sitemap — eslint.org's lists 499 URLs
200
+ // and not that one — so it is fetched when it was not crawled. The fetcher
201
+ // caches, and the audit has already asked for it to settle the host, so this
202
+ // costs nothing.
203
+ let homeDoc = homePage?.doc;
204
+ if (!homeDoc) {
205
+ const res = await fetcher.get(`${origin}/`);
206
+ if (res.ok && /text\/html/i.test(res.headers.get('content-type') ?? '')) {
207
+ homeDoc = parseHtml(res.body, `${origin}/`);
208
+ }
209
+ }
210
+ const declared = homeDoc?.icons?.[0];
211
+ // `data:,` is the empty data URI people use to stop a browser asking for a
212
+ // favicon at all. example.com and motherfuckingwebsite.com both ship it. It
213
+ // is a deliberate choice and there is nothing to fetch, so it is left alone.
214
+ if (homeDoc && !/^data:/i.test(declared ?? '')) {
215
+ const target = declared ?? new URL('/favicon.ico', origin).toString();
216
+ const res = await fetcher.get(target);
217
+ const type = res.headers.get('content-type') ?? '';
218
+ // 403 is hotlink protection working as designed, the same judgement the
219
+ // og:image sweep makes. Only an answer that means "not here" counts — and
220
+ // a page counts, because the catch-all handler answering 200 with HTML
221
+ // reaches a search engine as no icon just as surely as a 404 does.
222
+ const missing = absent(res);
223
+ const isPage = res.ok && /text\/html/i.test(type);
224
+ const because = missing
225
+ ? `answers ${res.status || res.error}`
226
+ : `answers 200 with ${type.split(';')[0]} — the site's catch-all handler rather than an icon`;
227
+
228
+ if (missing || isPage) {
229
+ if (declared) {
230
+ out.push(f('warn', 'favicon-broken', 'The declared favicon does not load',
231
+ `${target} ${because}. The home page asks for it by name, so search results fall back to a ` +
232
+ 'default icon on every page of the site.', origin));
233
+ } else {
234
+ out.push(f('info', 'favicon-missing', 'No favicon',
235
+ `The home page declares none and ${target} ${because}. Search results show a default icon ` +
236
+ 'beside every page of the site. Google wants a square, at least 8×8 and better above 48×48.',
237
+ origin));
238
+ }
239
+ }
240
+ }
241
+
242
+ // --- Canonical host and scheme -----------------------------------------
243
+ // One hop is right. Two means every visitor pays for a wasted round trip.
244
+ //
245
+ // `www.` is only meaningful for a registrable domain. An IP address has no
246
+ // www, and neither does a bare hostname like localhost — asking a resolver
247
+ // for `www.127.0.0.1` is a question with no sensible answer, which it may
248
+ // decline quickly or sit on for as long as it likes. That is what made the
249
+ // fixture tests, which run against 127.0.0.1, stall unpredictably.
250
+ const authority = base.host.replace(/^www\./, ''); // keeps any port
251
+ const isAddress = /^\[?[\d.:]+\]?$/.test(base.hostname);
252
+ const hasRegistrableDomain = !isAddress && base.hostname.replace(/^www\./, '').includes('.');
253
+ const variants = [
254
+ `http://${authority}/`,
255
+ ...(hasRegistrableDomain ? [`https://www.${authority}/`, `http://www.${authority}/`] : []),
256
+ ];
257
+ for (const variant of variants) {
258
+ const { hops, final } = await fetcher.chain(variant);
259
+ if (final.status === 429) {
260
+ // "Ask later" is not "dead". Calling a variant broken because the server
261
+ // declined to answer this crawler reports the crawl as a fault of the
262
+ // site — and it is the canonical host that gets called dead most often,
263
+ // because it is the one the crawl has already been hammering.
264
+ out.push(f('info', 'host-variant-not-checked', `${variant} was not checked`,
265
+ 'The server answered HTTP 429 — asking for a slower crawl — so whether this variant reaches ' +
266
+ 'a page is not known. Run it again with a lower --concurrency.', variant));
267
+ } else if (!final.ok) {
268
+ out.push(f('warn', 'host-variant-dead', `${variant} does not resolve to a page`,
269
+ final.error ? `Request failed: ${final.error}` : `Ends at HTTP ${final.status}`, variant));
270
+ } else if (hops.length > 2) {
271
+ out.push(f('warn', 'redirect-chain', `${variant} takes ${hops.length - 1} redirects`,
272
+ hops.map((h) => `${h.status} ${h.url}`).join(' → '), variant));
273
+ }
274
+ }
275
+
276
+ // --- Certificate --------------------------------------------------------
277
+ // Not an SEO check, and the only thing here that takes a site off the
278
+ // internet completely. A browser refuses to load an expired certificate, so
279
+ // the ranking becomes irrelevant along with everything else.
280
+ if (base.protocol === 'https:') {
281
+ // Injectable so the thresholds can be tested without a live certificate
282
+ // that would have to be reissued to keep the test meaningful.
283
+ const readExpiry = opts.readCertificateExpiry ?? certificateExpiry;
284
+ const expiresAt = await readExpiry(base.hostname);
285
+ if (expiresAt) {
286
+ const days = Math.floor((expiresAt - (opts.now ?? Date.now())) / DAY);
287
+ const on = new Date(expiresAt).toISOString().slice(0, 10);
288
+ if (days < 0) {
289
+ out.push(f('error', 'tls-expired', `The TLS certificate expired ${-days} day(s) ago`,
290
+ `It ran out on ${on}. Browsers refuse to load the site, so nothing else in this report matters ` +
291
+ 'until it is renewed.', origin));
292
+ } else if (days <= CERT_WARN_DAYS) {
293
+ out.push(f('warn', 'tls-expiring', `The TLS certificate expires in ${days} day(s)`,
294
+ `On ${on}. Usually this means automatic renewal has stopped without anyone noticing — the ` +
295
+ 'certificates that lapse are the ones nobody was worried about.', origin));
296
+ }
297
+ }
298
+ }
299
+
300
+ // --- Security headers ---------------------------------------------------
301
+ const home = await fetcher.get(base.origin + '/');
302
+ const header = (name) => home.headers.get(name);
303
+ const expected = [
304
+ ['strict-transport-security', 'warn', 'HSTS not set', 'Browsers will try HTTP first on the next visit.'],
305
+ ['x-content-type-options', 'info', 'X-Content-Type-Options not set', 'nosniff stops MIME-type guessing.'],
306
+ ['referrer-policy', 'info', 'Referrer-Policy not set', 'Full URLs leak to third parties by default.'],
307
+ ['content-security-policy', 'info', 'No Content-Security-Policy', 'The strongest defence against injected scripts.'],
308
+ ];
309
+ for (const [name, level, title, detail] of expected) {
310
+ if (!header(name)) out.push(f(level, `header-${name}`, title, detail, home.url));
311
+ }
312
+
313
+ // --- Broken internal links ---------------------------------------------
314
+ // Every internal href on every crawled page, checked once.
315
+ const known = new Set(pages.map((p) => p.url.replace(/\/$/, '')));
316
+ // Cloudflare rewrites mailto: links to /cdn-cgi/l/email-protection, which
317
+ // answers 404 to anything that is not a browser running their script. It is
318
+ // not a broken link, it is an anti-spam measure working as designed.
319
+ const notReallyBroken = /\/cdn-cgi\//;
320
+ const seen = new Map(); // target → pages linking to it
321
+ for (const page of pages) {
322
+ for (const href of page.doc?.links.internal ?? []) {
323
+ const clean = href.split('#')[0];
324
+ const bare = clean.replace(/\/$/, '');
325
+ if (known.has(bare) || inSitemap.has(bare) || notReallyBroken.test(clean)) continue;
326
+ seen.set(clean, [...(seen.get(clean) ?? []), page.url]);
327
+ }
328
+ }
329
+ // Both questions below — is the target broken, and is it missing from the
330
+ // sitemap — are answered by the same response, so ask once and read it twice.
331
+ //
332
+ // The fetcher caches, so the old second pass was free for anything already
333
+ // checked. What it was not free for was everything past maxLinkChecks: that
334
+ // pass looped over every target, uncapped and one at a time, so the cap
335
+ // bounded the broken-link check but not the run. A site with 500 link targets
336
+ // paid for 300 serial requests that nothing was capping.
337
+ const all = [...seen.keys()];
338
+ const limit = opts.maxLinkChecks ?? 200;
339
+ const targets = all.slice(0, limit);
340
+ opts.onProgress?.({ phase: 'links', detail: `${targets.length} distinct targets to check` });
341
+ const results = await mapLimit(targets, 6, async (target) => {
342
+ const res = await fetcher.get(target);
343
+ opts.onProgress?.({ phase: 'links', status: res.status, ms: res.ms, url: target });
344
+ const type = res.headers.get('content-type') ?? '';
345
+ // A third question the same response answers — and the only place it can be
346
+ // asked. A sitemap does not list page 2 of an archive: across css-tricks,
347
+ // wordpress.org and smashingmagazine, 0 of 9,273 sitemap URLs were
348
+ // paginated, so these pages are met here or not at all.
349
+ //
350
+ // Read now rather than by keeping the body: a sweep of two hundred targets
351
+ // holding two hundred documents in memory to read one tag out of a handful
352
+ // of them is not a trade worth making.
353
+ const canonical =
354
+ res.ok && /text\/html/i.test(type) && seriesOf(target).page > 1
355
+ ? (parseHtml(res.body, target).canonical?.[0] ?? null)
356
+ : null;
357
+ return { target, status: res.status, type, canonical };
358
+ });
359
+
360
+ if (all.length > targets.length) {
361
+ out.push(f('info', 'link-sweep-capped', `${all.length - targets.length} link targets were not checked`,
362
+ `The sweep stops at ${limit} distinct targets. Raise it with maxLinkChecks in the config — ` +
363
+ 'the rest of this section describes only what was actually fetched.', origin));
364
+ }
365
+
366
+ // Iterated in link order rather than whichever request finished first, so two
367
+ // runs of an unchanged site produce the same report and --baseline stays
368
+ // meaningful.
369
+ for (const { target, status } of results) {
370
+ if (status === 404 || status === 0) {
371
+ out.push(f('error', 'broken-link', 'Link to a page that does not exist',
372
+ `${target} — linked from ${seen.get(target).slice(0, 3).join(', ')}`, seen.get(target)[0]));
373
+ }
374
+ }
375
+
376
+ for (const { target, canonical } of results) {
377
+ const finding = paginatedCanonical(target, canonical);
378
+ if (finding) out.push(finding);
379
+ }
380
+
381
+ // Linked, reachable, and absent from the sitemap — the mirror image of an
382
+ // orphan, and just as easy to ship by accident when a route is added.
383
+ //
384
+ // Silent when the crawl followed links rather than a sitemap: every page
385
+ // found that way is by definition absent from a sitemap that does not exist,
386
+ // and saying so once per page would bury the finding that matters, which is
387
+ // that there is no sitemap at all.
388
+ const missing =
389
+ opts.bySitemap === false
390
+ ? []
391
+ : results.filter((r) => r.status === 200 && /text\/html/i.test(r.type));
392
+ for (const { target } of missing.slice(0, 20)) {
393
+ out.push(f('warn', 'missing-from-sitemap', 'Page is linked but not in the sitemap',
394
+ `${target} — linked from ${seen.get(target).slice(0, 2).join(', ')}`, target));
395
+ }
396
+ if (missing.length > 20) {
397
+ out.push(f('info', 'missing-from-sitemap-more', `${missing.length - 20} more pages are linked but not in the sitemap`,
398
+ `${missing.length} in total; the first 20 are listed above. This usually means one route or ` +
399
+ 'section never made it into the generator’s sitemap, so look for the pattern rather than fixing them one by one.', origin));
400
+ }
401
+
402
+ // An internal link that redirects still works, so it is never urgent — but
403
+ // every one of them spends a round trip that a visitor and a crawler both
404
+ // pay for, and they accumulate silently after a URL structure changes.
405
+ // Aggregated and filed as a note: keeping an old permalink alive on purpose
406
+ // is a legitimate reason to have one.
407
+ const redirecting = results.filter((r) => r.status >= 300 && r.status < 400);
408
+ if (redirecting.length) {
409
+ out.push(f('info', 'link-redirects', `${redirecting.length} internal link(s) point at a redirect`,
410
+ `First: ${redirecting.slice(0, 3).map((r) => `${r.target} (${r.status})`).join(', ')}. ` +
411
+ 'Linking to the final URL saves the hop.', origin));
412
+ }
413
+
414
+ // --- Images that do not load --------------------------------------------
415
+ // The link sweep above reads anchors only, so a broken <img> on page 23 has
416
+ // never been visible to this tool — which is the exact shape of bug it was
417
+ // written for.
418
+ //
419
+ // Deliberately conservative about what counts as broken. A 403 is the
420
+ // signature of hotlink protection working as designed, not of a missing file,
421
+ // and reporting those would be the /cdn-cgi/ mistake a second time.
422
+ // Counted by file rather than by URL. An image CDN serves one file at every
423
+ // size asked for — /cdn/shop/files/DSC_0075-2.avif?v=…&width=150, &width=300,
424
+ // &width=750 — and each of those used to be a separate entry against the cap.
425
+ // Measured across 45 pages of a real store: 767 distinct URLs, 488 distinct
426
+ // files, so a third of the sweep was asking the same question again.
427
+ //
428
+ // Only the size knobs are dropped. `v` stays: a different version is a
429
+ // different asset and a stale one really can 404, which is a finding worth
430
+ // keeping. The trade is that one size is checked on behalf of the others —
431
+ // if a CDN refuses an unusual width the sweep will miss it, which errs
432
+ // towards saying nothing rather than towards saying something wrong.
433
+ const SIZE_PARAMS = ['width', 'height', 'w', 'h', 'dpr'];
434
+ const imageFile = (url) => {
435
+ try {
436
+ const u = new URL(url);
437
+ for (const param of SIZE_PARAMS) u.searchParams.delete(param);
438
+ return u.toString();
439
+ } catch {
440
+ return url;
441
+ }
442
+ };
443
+
444
+ const imageSources = new Map();
445
+ for (const page of pages) {
446
+ for (const img of page.doc?.images ?? []) {
447
+ if (!img.src || /^data:/i.test(img.src)) continue;
448
+ let absolute;
449
+ try {
450
+ absolute = new URL(img.src, page.url).toString();
451
+ } catch {
452
+ continue;
453
+ }
454
+ const file = imageFile(absolute);
455
+ if (!imageSources.has(file)) imageSources.set(file, { src: absolute, page: page.url });
456
+ }
457
+ }
458
+ const imageLimit = opts.maxImageChecks ?? 200;
459
+ const imageTargets = [...imageSources.values()].slice(0, imageLimit).map((entry) => entry.src);
460
+ opts.onProgress?.({ phase: 'images', detail: `${imageTargets.length} distinct images to check` });
461
+ const imageResults = await mapLimit(imageTargets, 6, async (src) => {
462
+ let res = await fetcher.get(src, { method: 'HEAD' });
463
+ // Some hosts answer HEAD with 405 or 501 and serve the file perfectly well.
464
+ if (res.status === 405 || res.status === 501) res = await fetcher.get(src);
465
+ opts.onProgress?.({ phase: 'images', status: res.status, ms: res.ms, url: src });
466
+ return { src, status: res.status, error: res.error };
467
+ });
468
+ // In source order, not completion order, so two runs of an unchanged site
469
+ // produce the same report.
470
+ for (const { src, status, error } of imageResults) {
471
+ if (status === 404 || status === 410 || status === 0) {
472
+ const on = imageSources.get(imageFile(src))?.page;
473
+ out.push(f('error', 'broken-image', 'Image does not load',
474
+ `HTTP ${status || error} for ${src} — used on ${on}.`, on));
475
+ }
476
+ }
477
+ if (imageSources.size > imageTargets.length) {
478
+ out.push(f('info', 'image-sweep-capped', `${imageSources.size - imageTargets.length} images were not checked`,
479
+ `The sweep stops at ${imageLimit} distinct files and this site has ${imageSources.size}. Set ` +
480
+ `"maxImageChecks": ${imageSources.size} in the config to check them all — each one is a request, ` +
481
+ 'so a large catalogue is a long run.', origin));
482
+ }
483
+
484
+ // --- Outbound links -------------------------------------------------------
485
+ // Off by default, and that is a judgement rather than laziness. These are
486
+ // other people's servers: they rate-limit, they bot-block, they answer 403 to
487
+ // anything without a browser's fingerprint. Reporting that as a broken link
488
+ // would be the most productive false positive this tool could invent, so only
489
+ // 404, 410 and a dead connection count — and even then it is opt-in, because
490
+ // one machine hammering a hundred third parties is rude at scale.
491
+ if (opts.checkExternal) {
492
+ const outbound = new Map();
493
+ for (const page of pages) {
494
+ for (const href of page.doc?.links.external ?? []) {
495
+ if (!/^https?:/i.test(href)) continue;
496
+ if (!outbound.has(href)) outbound.set(href, page.url);
497
+ }
498
+ }
499
+ const externalLimit = opts.maxExternalChecks ?? 100;
500
+ const externalTargets = [...outbound.keys()].slice(0, externalLimit);
501
+ opts.onProgress?.({ phase: 'external', detail: `${externalTargets.length} outbound links to check` });
502
+
503
+ const externalResults = await mapLimit(externalTargets, 4, async (href) => {
504
+ const { hops, final } = await fetcher.chain(href);
505
+ opts.onProgress?.({ phase: 'external', status: final.status, ms: final.ms, url: href });
506
+ return { href, first: hops[0]?.status ?? 0, final };
507
+ });
508
+
509
+ const dead = externalResults.filter(
510
+ (r) => r.final.status === 404 || r.final.status === 410 || r.final.status === 0,
511
+ );
512
+ if (dead.length) {
513
+ out.push(f('warn', 'external-broken', `${dead.length} outbound link(s) do not resolve`,
514
+ `${dead.slice(0, 3).map((r) => `${r.href} (${r.final.status || r.final.error})`).join(', ')}` +
515
+ `${dead.length > 3 ? `, and ${dead.length - 3} more` : ''}. A link out that goes nowhere is a dead ` +
516
+ 'end for a reader. Checked leniently — anything but a 404, a 410 or no answer at all is left alone.',
517
+ origin));
518
+ }
519
+
520
+ const moved = externalResults.filter((r) => r.first >= 300 && r.first < 400 && r.final.ok);
521
+ if (moved.length) {
522
+ out.push(f('info', 'external-redirects', `${moved.length} outbound link(s) point at a redirect`,
523
+ `${moved.slice(0, 3).map((r) => `${r.href} → ${r.final.url}`).join(', ')}` +
524
+ `${moved.length > 3 ? `, and ${moved.length - 3} more` : ''}. They work; linking to the final ` +
525
+ 'URL is tidier and survives the day the redirect is removed.', origin));
526
+ }
527
+
528
+ if (outbound.size > externalTargets.length) {
529
+ out.push(f('info', 'external-sweep-capped', `${outbound.size - externalTargets.length} outbound links were not checked`,
530
+ `The sweep stops at ${externalLimit}. Raise it with maxExternalChecks.`, origin));
531
+ }
532
+ }
533
+
534
+ // --- Images named in structured data ------------------------------------
535
+ // A logo or an image Google is told to use for a rich result, that does not
536
+ // load. Nothing on the page looks wrong — the markup is valid and the file is
537
+ // simply gone, usually a media library tidied up years after the JSON-LD was
538
+ // written. Same conservative rule as everywhere else: 404, 410 or no answer.
539
+ const schemaImages = new Map();
540
+ for (const page of pages) {
541
+ for (const node of schemaNodes(page.doc?.jsonld)) {
542
+ for (const key of ['image', 'logo', 'thumbnailUrl', 'contentUrl']) {
543
+ for (const value of [node[key]].flat()) {
544
+ const href = typeof value === 'string' ? value : value?.url;
545
+ if (typeof href !== 'string' || !/^https?:/i.test(href)) continue;
546
+ if (!schemaImages.has(href)) schemaImages.set(href, page.url);
547
+ }
548
+ }
549
+ }
550
+ }
551
+ const schemaTargets = [...schemaImages.keys()].slice(0, opts.maxImageChecks ?? 200);
552
+ const schemaResults = await mapLimit(schemaTargets, 4, async (href) => {
553
+ let res = await fetcher.get(href, { method: 'HEAD' });
554
+ if (res.status === 405 || res.status === 501) res = await fetcher.get(href);
555
+ return { href, status: res.status, error: res.error };
556
+ });
557
+ const deadSchemaImages = schemaResults.filter(
558
+ (r) => r.status === 404 || r.status === 410 || r.status === 0,
559
+ );
560
+ if (deadSchemaImages.length) {
561
+ out.push(f('warn', 'schema-image-broken', `${deadSchemaImages.length} image(s) named in structured data do not load`,
562
+ `${deadSchemaImages.slice(0, 3).map((r) => `${r.href} (${r.status || r.error})`).join(', ')}` +
563
+ `${deadSchemaImages.length > 3 ? `, and ${deadSchemaImages.length - 3} more` : ''}. Google is told to ` +
564
+ 'use these for rich results and finds nothing there. The markup is valid, so nothing else reports it.',
565
+ deadSchemaImages[0] ? schemaImages.get(deadSchemaImages[0].href) : origin));
566
+ }
567
+
568
+ // --- hreflang targets load ----------------------------------------------
569
+ // A version that does not load is dropped from the set, and the pages that
570
+ // pointed at it lose the annotation with it. Targets already crawled and
571
+ // answering 200 are not asked again; the interesting ones are the alternates
572
+ // outside the crawl, which is where a stale translation URL survives.
573
+ const crawledOk = new Set(pages.filter((p) => p.res.ok).map((p) => p.url.replace(/\/$/, '')));
574
+ const alternates = new Map();
575
+ for (const page of pages) {
576
+ for (const alt of page.doc?.hreflang ?? []) {
577
+ if (!alt.href || crawledOk.has(alt.href.replace(/\/$/, ''))) continue;
578
+ if (!alternates.has(alt.href)) alternates.set(alt.href, page.url);
579
+ }
580
+ }
581
+ const alternateResults = await mapLimit(
582
+ [...alternates.keys()].slice(0, limit),
583
+ 6,
584
+ async (href) => {
585
+ const res = await fetcher.get(href);
586
+ return { href, status: res.status, error: res.error };
587
+ },
588
+ );
589
+ // Grouped by the page that declares them. A translated site tends to carry
590
+ // one alternate per locale, so a single broken page can produce forty
591
+ // identical findings — wordpress.org declares fifty-two locale subdomains for
592
+ // a page that exists in seven of them. One finding per page, naming a few.
593
+ const deadByPage = new Map();
594
+ for (const { href, status, error } of alternateResults) {
595
+ if (status !== 404 && status !== 410 && status !== 0) continue;
596
+ const source = alternates.get(href);
597
+ deadByPage.set(source, [...(deadByPage.get(source) ?? []), `${href} (${status || error})`]);
598
+ }
599
+ for (const [source, dead] of deadByPage) {
600
+ const shown = dead.slice(0, 3).join(', ');
601
+ out.push(f('error', 'hreflang-dead', `${dead.length} hreflang target(s) do not load`,
602
+ `${shown}${dead.length > 3 ? `, and ${dead.length - 3} more` : ''} — declared on ${source}. Each ` +
603
+ 'version that does not load drops out of the set, and the pages pointing at it lose the annotation.',
604
+ source));
605
+ }
606
+
607
+ // --- Canonical targets --------------------------------------------------
608
+ // A canonical pointing at a redirect or a 404 is worse than none: Google is
609
+ // told the real page lives somewhere that does not answer.
610
+ const canonicals = new Map();
611
+ for (const page of pages) {
612
+ const target = page.doc?.canonical?.[0];
613
+ if (!target) continue;
614
+ if (target.replace(/\/$/, '') === page.url.replace(/\/$/, '')) continue;
615
+ canonicals.set(target, page.url);
616
+ }
617
+ const canonicalResults = await mapLimit([...canonicals.keys()], 4, async (target) => {
618
+ const res = await fetcher.get(target);
619
+ return { target, res };
620
+ });
621
+ for (const { target, res } of canonicalResults) {
622
+ const from = canonicals.get(target);
623
+ if (res.status >= 300 && res.status < 400) {
624
+ out.push(f('error', 'canonical-redirects', 'Canonical points at a redirect',
625
+ `${target} answers ${res.status}. Point it at the final URL.`, from));
626
+ continue;
627
+ }
628
+ if (!res.ok) {
629
+ out.push(f('error', 'canonical-dead', 'Canonical points at a page that does not load',
630
+ `${target} answers ${res.status}.`, from));
631
+ continue;
632
+ }
633
+ if (!/text\/html/i.test(res.headers.get('content-type') ?? '')) continue;
634
+ const targetDoc = parseHtml(res.body, target);
635
+
636
+ // The target loads, and it says not to index it. A canonical is a request
637
+ // to index B in place of A, so A follows B out of the index and takes the
638
+ // page that was actually meant to rank with it. Nothing on A shows this:
639
+ // its own markup is correct, and the instruction that removes it lives on
640
+ // a different page — or, worse, in a header that no view-source reveals.
641
+ const targetRobots = `${targetDoc.robots ?? ''} ${res.headers?.get?.('x-robots-tag') ?? ''}`;
642
+ if (/noindex/i.test(targetRobots)) {
643
+ out.push(f('error', 'canonical-noindex', 'Canonical points at a noindexed page',
644
+ `${target} is noindex ("${targetRobots.trim()}"), and ${from} hands its indexing over to it. ` +
645
+ 'Both pages leave the index: the target because it asked to, and this one because it named ' +
646
+ 'the target as the version to keep.', from));
647
+ continue;
648
+ }
649
+
650
+ // The target loads — but does it claim to be canonical itself? A → B where
651
+ // B hands off to C makes Google follow a chain it is under no obligation to
652
+ // follow, and the page that started it can end up consolidated nowhere.
653
+ const theirs = targetDoc.canonical?.[0];
654
+ if (theirs && theirs.replace(/\/$/, '') !== target.replace(/\/$/, '')) {
655
+ out.push(f('warn', 'canonical-chain', 'Canonical points at a page that canonicals somewhere else',
656
+ `${from} → ${target} → ${theirs}. Google is not obliged to follow a chain; point the first ` +
657
+ 'canonical at the page that actually claims itself.', from));
658
+ }
659
+ }
660
+
661
+ // --- Trailing slashes ---------------------------------------------------
662
+ // Both forms serving 200 is two URLs for one page, and Google will pick one
663
+ // for you. A redirect between them is correct; two live copies are not.
664
+ const sample = pages.filter((p) => p.res.ok && new URL(p.url).pathname !== '/').slice(0, 12);
665
+ let inconsistent = 0;
666
+ await mapLimit(sample, 4, async (page) => {
667
+ const url = new URL(page.url);
668
+ const flipped = url.pathname.endsWith('/')
669
+ ? page.url.replace(/\/$/, '')
670
+ : `${page.url}/`;
671
+ const res = await fetcher.get(flipped);
672
+ if (res.status === 200) inconsistent++;
673
+ });
674
+ if (inconsistent) {
675
+ out.push(f('warn', 'trailing-slash', 'Pages answer with and without a trailing slash',
676
+ `${inconsistent} of ${sample.length} sampled pages load both ways, which is two URLs for one page. ` +
677
+ 'One form should redirect to the other.', origin));
678
+ }
679
+
680
+ // --- Social images actually load ---------------------------------------
681
+ const ogImages = new Map();
682
+ for (const page of pages) {
683
+ const src = page.doc?.og['og:image'];
684
+ // A relative og:image is reported as og-image-relative by the page checks,
685
+ // which explains the actual problem. Fetching it here would only add a
686
+ // second, vaguer finding about the same tag.
687
+ if (src && /^(https?:)?\/\//i.test(src)) ogImages.set(src, page.url);
688
+ }
689
+ // The chain is followed and only the final answer judged. An og:image on
690
+ // http:// that 301s to https loads perfectly well — every scraper follows it —
691
+ // and allbirds.com had seven of those reported as previewing blank.
692
+ //
693
+ // Conservative about what counts as broken, for the same reason as the image
694
+ // sweep: 403 is hotlink protection working, not a missing file.
695
+ const ogResults = await mapLimit([...ogImages.keys()], 4, async (src) => {
696
+ const { final } = await fetcher.chain(src);
697
+ return { src, final };
698
+ });
699
+ for (const { src, final } of ogResults) {
700
+ if (final.status === 404 || final.status === 410 || final.status === 0) {
701
+ out.push(f('error', 'og-image-broken', 'og:image does not load',
702
+ `HTTP ${final.status || final.error} for ${src} — shared links will preview blank.`,
703
+ ogImages.get(src)));
704
+ continue;
705
+ }
706
+ const bytes = Number(final.headers.get('content-length') ?? 0);
707
+ if (bytes > 5_000_000) {
708
+ out.push(f('warn', 'og-image-heavy', 'og:image is very large',
709
+ `${(bytes / 1e6).toFixed(1)}MB — some scrapers give up before downloading it.`, ogImages.get(src)));
710
+ }
711
+ }
712
+
713
+ return out;
714
+ }