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