@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/checks.mjs
ADDED
|
@@ -0,0 +1,1253 @@
|
|
|
1
|
+
// The checks.
|
|
2
|
+
//
|
|
3
|
+
// Scope: correctness that holds across every page of a site. Performance is
|
|
4
|
+
// never *estimated* here — see src/psi.mjs, which asks Google for the real
|
|
5
|
+
// measurement instead. What this covers is the layer single-page graders skip:
|
|
6
|
+
// they audit one URL, and the problems that matter usually live on page 23.
|
|
7
|
+
//
|
|
8
|
+
// A finding is { id, level, title, detail, url }.
|
|
9
|
+
// error — wrong, and costs traffic or breaks something
|
|
10
|
+
// warn — worth fixing, judgement involved
|
|
11
|
+
// info — worth knowing, may be deliberate
|
|
12
|
+
|
|
13
|
+
import { linkGraph, key as graphKey } from './graph.mjs';
|
|
14
|
+
|
|
15
|
+
import { attr, stripMarkupInAttributes } from './parse.mjs';
|
|
16
|
+
import { cluster } from './dupes.mjs';
|
|
17
|
+
|
|
18
|
+
// Defaults, overridable per site under `limits` in the config file. A
|
|
19
|
+
// documentation site and a shop disagree about what "thin" means, and the tool
|
|
20
|
+
// should not hold the opinion.
|
|
21
|
+
export const DEFAULT_LIMITS = {
|
|
22
|
+
titleMin: 15,
|
|
23
|
+
titleMax: 60,
|
|
24
|
+
descMin: 70,
|
|
25
|
+
descMax: 160,
|
|
26
|
+
thinWords: 300,
|
|
27
|
+
slowMs: 800,
|
|
28
|
+
maxClickDepth: 4,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// --- Alt text ---------------------------------------------------------------
|
|
32
|
+
// An alt that is really a filename — what a CMS fills in when nobody typed
|
|
33
|
+
// anything. `.jpg` at the end, or the shape a camera and a phone both produce.
|
|
34
|
+
const ALT_FILENAME = /\.(jpe?g|png|gif|webp|svg|avif)$/i;
|
|
35
|
+
const ALT_SERIAL = /^(img|dsc|dscn|pxl|photo|image|screenshot|untitled)[-_ ]?\d+$/i;
|
|
36
|
+
|
|
37
|
+
// Words that name the medium rather than the content. A screen reader already
|
|
38
|
+
// announces "image" before reading the alt, so alt="image" says it twice and
|
|
39
|
+
// tells nobody anything.
|
|
40
|
+
const ALT_PLACEHOLDER = new Set([
|
|
41
|
+
'image', 'photo', 'picture', 'img', 'icon', 'logo', 'graphic', 'banner',
|
|
42
|
+
'thumbnail', 'untitled', 'alt', 'alt text', 'image of', 'photo of',
|
|
43
|
+
'spacer', 'placeholder', 'no alt', 'none',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
// A screen reader reads alt in one breath, with no way to skim or pause.
|
|
47
|
+
const ALT_MAX = 125;
|
|
48
|
+
|
|
49
|
+
// --- Pagination -------------------------------------------------------------
|
|
50
|
+
// Only the two shapes that can be read without guessing. `/page/2/` is what
|
|
51
|
+
// WordPress, Ghost, Hugo, Eleventy and Astro all generate, and `?page=2` is
|
|
52
|
+
// what most of the rest do.
|
|
53
|
+
//
|
|
54
|
+
// Deliberately absent: a bare trailing number like `/blog/2/`, which is just as
|
|
55
|
+
// often a year or an id; `?p=2`, which is a WordPress *post* id and not a page
|
|
56
|
+
// of anything; and `?start=`/`?offset=`, where the first page is not a number
|
|
57
|
+
// this can work back to. A shape that has to be guessed at is not read at all.
|
|
58
|
+
const PAGE_IN_PATH = /\/page\/(\d+)\/?$/i;
|
|
59
|
+
const PAGE_PARAMS = ['page', 'paged'];
|
|
60
|
+
|
|
61
|
+
/** The sequence a URL belongs to: where it starts, and which page this is.
|
|
62
|
+
*
|
|
63
|
+
* A URL carrying no pagination is page 1 of its own sequence, which makes the
|
|
64
|
+
* two comparable without a special case at the call site. */
|
|
65
|
+
export function seriesOf(rawUrl) {
|
|
66
|
+
let url;
|
|
67
|
+
try {
|
|
68
|
+
url = new URL(rawUrl);
|
|
69
|
+
} catch {
|
|
70
|
+
return { base: rawUrl, page: 1 };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const inPath = url.pathname.match(PAGE_IN_PATH);
|
|
74
|
+
if (inPath) {
|
|
75
|
+
const base = new URL(url);
|
|
76
|
+
base.pathname = url.pathname.replace(PAGE_IN_PATH, '/');
|
|
77
|
+
return { base: base.toString(), page: Number(inPath[1]) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const param of PAGE_PARAMS) {
|
|
81
|
+
const value = url.searchParams.get(param);
|
|
82
|
+
if (value === null || !/^\d+$/.test(value)) continue;
|
|
83
|
+
const base = new URL(url);
|
|
84
|
+
base.searchParams.delete(param);
|
|
85
|
+
return { base: base.toString(), page: Number(value) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { base: url.toString(), page: 1 };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Page 2 of an archive handing its indexing to page 1 — or to any other page
|
|
92
|
+
* of the same sequence — as a finding, or null.
|
|
93
|
+
*
|
|
94
|
+
* Google's guidance is one sentence long and unambiguous: "Don't use the first
|
|
95
|
+
* page of a paginated sequence as the canonical page." Page 2 is not the same
|
|
96
|
+
* content as page 1, so the request is one Google is under no obligation to
|
|
97
|
+
* honour and may simply ignore; where it is honoured, the whole archive after
|
|
98
|
+
* the first page leaves the index, taking with it the only route to every
|
|
99
|
+
* article old enough to have fallen off page 1. It is a default that arrives
|
|
100
|
+
* switched on rather than something anyone chose — css-tricks.com,
|
|
101
|
+
* wordpress.org/news, smashingmagazine.com and blog.mozilla.org all ship it.
|
|
102
|
+
*
|
|
103
|
+
* Shared with the link sweep in src/site.mjs, which is where these pages are
|
|
104
|
+
* usually met: a sitemap almost never lists them. */
|
|
105
|
+
export function paginatedCanonical(url, canonical) {
|
|
106
|
+
if (!canonical) return null;
|
|
107
|
+
const here = seriesOf(url);
|
|
108
|
+
const target = seriesOf(canonical);
|
|
109
|
+
if (here.page < 2 || here.page === target.page) return null;
|
|
110
|
+
if (withoutSlash(here.base) !== withoutSlash(target.base)) return null;
|
|
111
|
+
return f('error', 'canonical-paginated', 'Canonical points at another page of the sequence',
|
|
112
|
+
`This is page ${here.page} and its canonical is ${canonical}` +
|
|
113
|
+
`${target.page === 1 ? ' — the first page' : ` — page ${target.page}`}. Google's guidance is ` +
|
|
114
|
+
'"Don\'t use the first page of a paginated sequence as the canonical page", because page ' +
|
|
115
|
+
`${here.page} is not the same content. Each page in a sequence should name itself.`, url);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// --- Anchor text ------------------------------------------------------------
|
|
119
|
+
// Words that describe the act of clicking rather than what is on the other
|
|
120
|
+
// side. Deliberately short and unarguable: every entry here is a phrase that
|
|
121
|
+
// would be identical on any link on any site, which is the whole complaint.
|
|
122
|
+
// "Get started", "Book now" and "Download the guide" are not on it — they say
|
|
123
|
+
// something about the destination, and a list that grows opinions instead of
|
|
124
|
+
// facts becomes a list that argues with people.
|
|
125
|
+
const GENERIC_ANCHORS = new Set([
|
|
126
|
+
'read more', 'read the rest', 'continue reading', 'more', 'more info',
|
|
127
|
+
'more information', 'learn more', 'find out more', 'click here', 'click',
|
|
128
|
+
'here', 'this', 'this page', 'this link', 'link', 'view', 'view more',
|
|
129
|
+
'see more', 'details', 'go', 'open',
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
// Words whose job is to be the same words in different places. A footer says
|
|
133
|
+
// "Contact" on every page of a site and means the same page each time; a
|
|
134
|
+
// language switcher says "English" beside every article. These are navigation,
|
|
135
|
+
// not description, and the check below is about description.
|
|
136
|
+
const NAVIGATION_ANCHORS = new Set([
|
|
137
|
+
'home', 'next', 'previous', 'prev', 'back', 'top', 'menu', 'search',
|
|
138
|
+
'close', 'skip to content', 'skip to main content', 'toggle navigation',
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
// The same job, phrased freely. smashingmagazine.com puts "Jump to table of
|
|
142
|
+
// contents" on every ebook page, each one pointing at its own; the words
|
|
143
|
+
// describe the movement, not the destination.
|
|
144
|
+
const CONTROL_PHRASE = /^(jump|skip|go|back|return|scroll) to\b/;
|
|
145
|
+
|
|
146
|
+
// A link whose text is a file format is labelling a download, not describing a
|
|
147
|
+
// page. elementor.com's brand page offers each logo as "SVG" and "PNG", which
|
|
148
|
+
// collides with every other logo on the same page and means nothing to anyone.
|
|
149
|
+
const ASSET_FILE =
|
|
150
|
+
/\.(svg|png|jpe?g|gif|webp|avif|pdf|zip|rar|gz|tar|tgz|mp[34]|mov|docx?|xlsx?|pptx?|csv|txt|xml|json|md5|sha\d*|asc|sig|exe|dmg|iso)$/i;
|
|
151
|
+
|
|
152
|
+
// Above this, a phrase is a label in a list rather than a description of a
|
|
153
|
+
// page. wordpress.org's download page says "md5" beside 2,730 checksums; that
|
|
154
|
+
// is a table, and reporting it as ambiguous anchor text would be reporting the
|
|
155
|
+
// existence of a table. A real collision is two or three pages.
|
|
156
|
+
const AMBIGUOUS_CEILING = 5;
|
|
157
|
+
|
|
158
|
+
const anchorPhrase = (name) =>
|
|
159
|
+
(name ?? '')
|
|
160
|
+
.toLowerCase()
|
|
161
|
+
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
|
162
|
+
.replace(/\s+/g, ' ')
|
|
163
|
+
.trim();
|
|
164
|
+
|
|
165
|
+
// --- hreflang ---------------------------------------------------------------
|
|
166
|
+
// A language, optionally a script, optionally a region, joined by hyphens:
|
|
167
|
+
// en, en-GB, zh-Hant, zh-Hant-TW, en-419. Case is not significant to Google.
|
|
168
|
+
// Only the shape is checked, not whether the codes exist — that would mean
|
|
169
|
+
// embedding the ISO lists, and a wrong list is worse than no check. The shape
|
|
170
|
+
// alone catches the common mistake, which is an underscore.
|
|
171
|
+
const LANGUAGE_TAG = /^[a-z]{2,3}(-[a-z]{4})?(-([a-z]{2}|\d{3}))?$/i;
|
|
172
|
+
const isLanguageTag = (tag) =>
|
|
173
|
+
(tag ?? '').toLowerCase() === 'x-default' || LANGUAGE_TAG.test(tag ?? '');
|
|
174
|
+
|
|
175
|
+
// Compare languages, not dialects: a page declaring lang="en-US" and hreflang
|
|
176
|
+
// "en" agrees with itself. Only the primary subtag is the claim about language.
|
|
177
|
+
const primaryLanguage = (tag) => (tag ?? '').split('-')[0].toLowerCase();
|
|
178
|
+
|
|
179
|
+
const withoutSlash = (u) => (u ?? '').replace(/\/$/, '');
|
|
180
|
+
|
|
181
|
+
// Two images sharing alt text is a judgement call — a gallery of near-identical
|
|
182
|
+
// product shots is a fair reason. A whole page of them is a template nobody
|
|
183
|
+
// filled in, so only report from three up.
|
|
184
|
+
const ALT_DUP_MIN = 3;
|
|
185
|
+
|
|
186
|
+
// Below this a response is not worth compressing and plenty of CDNs skip it.
|
|
187
|
+
const COMPRESSIBLE_FROM = 5 * 1024;
|
|
188
|
+
// Deliberately far above anything ordinary. Google's own limit is 15MB, so this
|
|
189
|
+
// is a signal rather than a threshold — and set high enough that a page has to
|
|
190
|
+
// be genuinely extraordinary to trip it.
|
|
191
|
+
const HUGE_HTML = 1024 * 1024;
|
|
192
|
+
|
|
193
|
+
// --- Structured data --------------------------------------------------------
|
|
194
|
+
// Google's documented requirements, for the types people actually ship and only
|
|
195
|
+
// the fields that have been stable for years. A rich result is refused outright
|
|
196
|
+
// when one is missing and nothing on the page says so — the markup validates,
|
|
197
|
+
// the type is right, and the result never appears.
|
|
198
|
+
//
|
|
199
|
+
// Deliberately short. Google's requirements move, and a list that goes stale
|
|
200
|
+
// invents findings on correct markup, which is the one failure this tool cannot
|
|
201
|
+
// afford. Anything uncertain is left out rather than guessed at.
|
|
202
|
+
const SCHEMA_REQUIRED = {
|
|
203
|
+
Article: ['headline'],
|
|
204
|
+
NewsArticle: ['headline'],
|
|
205
|
+
BlogPosting: ['headline'],
|
|
206
|
+
BreadcrumbList: ['itemListElement'],
|
|
207
|
+
FAQPage: ['mainEntity'],
|
|
208
|
+
Event: ['name', 'startDate', 'location'],
|
|
209
|
+
Organization: ['name'],
|
|
210
|
+
LocalBusiness: ['name', 'address'],
|
|
211
|
+
VideoObject: ['name', 'thumbnailUrl', 'uploadDate'],
|
|
212
|
+
Recipe: ['name', 'image'],
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// A Product needs a name and something to show: Google will not render a product
|
|
216
|
+
// result with no price, no review and no rating.
|
|
217
|
+
const PRODUCT_ONE_OF = ['offers', 'review', 'aggregateRating'];
|
|
218
|
+
|
|
219
|
+
/** Every node in a JSON-LD block that declares a type, references excluded.
|
|
220
|
+
*
|
|
221
|
+
* A node carrying `@id` and little else is a pointer to a definition made
|
|
222
|
+
* elsewhere — `"publisher": { "@id": "…#org" }` — not an incomplete node. */
|
|
223
|
+
export function schemaNodes(blocks) {
|
|
224
|
+
const found = [];
|
|
225
|
+
const walk = (node) => {
|
|
226
|
+
if (!node || typeof node !== 'object') return;
|
|
227
|
+
if (Array.isArray(node)) return node.forEach(walk);
|
|
228
|
+
const isReference = node['@id'] && Object.keys(node).length <= 2;
|
|
229
|
+
if (node['@type'] && !isReference) found.push(node);
|
|
230
|
+
for (const value of Object.values(node)) walk(value);
|
|
231
|
+
};
|
|
232
|
+
for (const block of blocks ?? []) if (block.ok) walk(block.data);
|
|
233
|
+
return found;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const present = (value) =>
|
|
237
|
+
value !== undefined && value !== null && value !== '' && !(Array.isArray(value) && !value.length);
|
|
238
|
+
|
|
239
|
+
const f = (level, id, title, detail, url) => ({ level, id, title, detail, url });
|
|
240
|
+
|
|
241
|
+
/** Checks that only need the page itself. */
|
|
242
|
+
export function pageChecks(page, limits = DEFAULT_LIMITS) {
|
|
243
|
+
const LIMITS = { ...DEFAULT_LIMITS, ...limits };
|
|
244
|
+
const { url, res, doc } = page;
|
|
245
|
+
const out = [];
|
|
246
|
+
|
|
247
|
+
if (res.status >= 300 && res.status < 400) {
|
|
248
|
+
out.push(
|
|
249
|
+
f('error', 'sitemap-redirect', 'Sitemap URL redirects',
|
|
250
|
+
`${res.status} → ${res.location}. A sitemap should list final URLs only.`, url),
|
|
251
|
+
);
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
// A rate limit is the server describing the crawl, not the page. Reported at
|
|
255
|
+
// info and never as a page that failed, because those two look identical in
|
|
256
|
+
// a report and only one of them is the site's problem.
|
|
257
|
+
if (res.status === 429) {
|
|
258
|
+
out.push(f('info', 'rate-limited', 'Page was not checked — the server is rate limiting',
|
|
259
|
+
'HTTP 429, after retries and a slower crawl. Nothing on this page was read, so its absence from ' +
|
|
260
|
+
'the rest of this report means nothing either. Run again with a lower --concurrency.', url));
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
if (!res.ok) {
|
|
264
|
+
out.push(f('error', 'page-status', 'Page did not return 200',
|
|
265
|
+
res.error ? `Request failed: ${res.error}` : `HTTP ${res.status}`, url));
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
if (!doc) return out;
|
|
269
|
+
|
|
270
|
+
// --- Indexability -------------------------------------------------------
|
|
271
|
+
if (/noindex/i.test(doc.robots ?? '')) {
|
|
272
|
+
out.push(f('error', 'noindex', 'Page is noindexed but listed in the sitemap',
|
|
273
|
+
`robots meta: "${doc.robots}"`, url));
|
|
274
|
+
}
|
|
275
|
+
// The same instruction, sent as a header. Nothing in the HTML shows it, so it
|
|
276
|
+
// survives every review of the markup and every tool that only reads the
|
|
277
|
+
// source — while binding Google exactly as hard as the meta tag.
|
|
278
|
+
const xRobots = res.headers?.get?.('x-robots-tag') ?? '';
|
|
279
|
+
if (/noindex/i.test(xRobots)) {
|
|
280
|
+
out.push(f('error', 'x-robots-noindex', 'Page is noindexed by an HTTP header',
|
|
281
|
+
`X-Robots-Tag: "${xRobots}" — invisible in the HTML, and the page is in the sitemap.`, url));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// nofollow on the page tells Google to follow none of its links — every one
|
|
285
|
+
// of them, including the navigation. On a page that exists to lead somewhere
|
|
286
|
+
// that is a dead end, and it is far less often deliberate than noindex.
|
|
287
|
+
const robotsDirectives = `${doc.robots ?? ''} ${xRobots}`;
|
|
288
|
+
if (/(^|[\s,])nofollow([\s,]|$)/i.test(robotsDirectives)) {
|
|
289
|
+
const alsoNoindex = /noindex/i.test(robotsDirectives);
|
|
290
|
+
out.push(f('warn', 'nofollow-page', alsoNoindex ? 'Page is noindex and nofollow' : 'Page is nofollow',
|
|
291
|
+
alsoNoindex
|
|
292
|
+
? `"${robotsDirectives.trim()}" — nothing here is indexed and no link out of it is followed, so this ` +
|
|
293
|
+
'page is a full stop for a crawler. Deliberate for a private area; a mistake on anything else.'
|
|
294
|
+
: `"${robotsDirectives.trim()}" — Google will follow none of the links on this page, navigation ` +
|
|
295
|
+
'included, so everything it links to loses that path in.', url));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Two sources for the same instruction, disagreeing. Google resolves it by
|
|
299
|
+
// taking the most restrictive, so the page ends up doing what neither author
|
|
300
|
+
// intended — and whichever file you are reading tells you the wrong story.
|
|
301
|
+
const metaSays = (doc.robots ?? '').toLowerCase();
|
|
302
|
+
const headerSays = xRobots.toLowerCase();
|
|
303
|
+
if (metaSays && headerSays) {
|
|
304
|
+
const conflicts = [
|
|
305
|
+
['index', /\bnoindex\b/, /(^|[\s,])index([\s,]|$)/],
|
|
306
|
+
['follow', /\bnofollow\b/, /(^|[\s,])follow([\s,]|$)/],
|
|
307
|
+
]
|
|
308
|
+
.filter(([, negative, positive]) =>
|
|
309
|
+
(negative.test(metaSays) && positive.test(headerSays)) ||
|
|
310
|
+
(negative.test(headerSays) && positive.test(metaSays)))
|
|
311
|
+
.map(([name]) => name);
|
|
312
|
+
if (conflicts.length) {
|
|
313
|
+
out.push(f('warn', 'robots-conflict', 'The robots meta tag and the header disagree',
|
|
314
|
+
`meta: "${doc.robots}" · X-Robots-Tag: "${xRobots}" — they contradict each other on ` +
|
|
315
|
+
`${conflicts.join(' and ')}. Google takes the most restrictive of the two, so the page does ` +
|
|
316
|
+
'what neither file says on its own.', url));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// A meta refresh is a redirect nothing treats as one: it costs a render, it
|
|
321
|
+
// passes signals poorly, and a visitor sees the wrong page first.
|
|
322
|
+
if (doc.refresh && /url=/i.test(doc.refresh)) {
|
|
323
|
+
const [seconds] = doc.refresh.split(';');
|
|
324
|
+
const to = doc.refresh.match(/url=\s*['"]?([^'";\s]+)/i)?.[1] ?? '';
|
|
325
|
+
out.push(f('warn', 'meta-refresh', 'Page redirects with a meta refresh',
|
|
326
|
+
`"${doc.refresh}" → ${to}. A 301 says the same thing to a crawler in one hop and passes the ` +
|
|
327
|
+
`signals properly.${Number(seconds) > 0 ? ' A delay also shows the visitor a page you did not mean them to read.' : ''}`,
|
|
328
|
+
url));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// --- Title & description ------------------------------------------------
|
|
332
|
+
if (!doc.title) {
|
|
333
|
+
out.push(f('error', 'title-missing', 'No <title>', 'Every page needs one.', url));
|
|
334
|
+
} else if (doc.title.length > LIMITS.titleMax) {
|
|
335
|
+
out.push(f('warn', 'title-long', 'Title may be truncated in results',
|
|
336
|
+
`${doc.title.length} chars (aim for under ${LIMITS.titleMax}): "${doc.title}"`, url));
|
|
337
|
+
} else if (doc.title.length < LIMITS.titleMin) {
|
|
338
|
+
out.push(f('warn', 'title-short', 'Title is very short',
|
|
339
|
+
`${doc.title.length} chars: "${doc.title}"`, url));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (!doc.description) {
|
|
343
|
+
out.push(f('warn', 'desc-missing', 'No meta description',
|
|
344
|
+
'Google will invent one from the page text.', url));
|
|
345
|
+
} else if (doc.description.length > LIMITS.descMax) {
|
|
346
|
+
out.push(f('warn', 'desc-long', 'Meta description will be cut off',
|
|
347
|
+
`${doc.description.length} chars (limit ~${LIMITS.descMax})`, url));
|
|
348
|
+
} else if (doc.description.length < LIMITS.descMin) {
|
|
349
|
+
out.push(f('info', 'desc-short', 'Meta description is short',
|
|
350
|
+
`${doc.description.length} chars — room to say more.`, url));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// --- Structure ----------------------------------------------------------
|
|
354
|
+
if (doc.h1.length === 0) out.push(f('error', 'h1-missing', 'No <h1>', 'The page has no headline.', url));
|
|
355
|
+
else if (doc.h1.length > 1) {
|
|
356
|
+
out.push(f('warn', 'h1-multiple', 'More than one <h1>',
|
|
357
|
+
`${doc.h1.length} found: ${doc.h1.slice(0, 3).map((h) => `"${h}"`).join(', ')}`, url));
|
|
358
|
+
}
|
|
359
|
+
if (!doc.lang) out.push(f('warn', 'lang-missing', 'No lang attribute on <html>', 'Screen readers and Google both use it.', url));
|
|
360
|
+
else {
|
|
361
|
+
// Two declarations of the same fact, disagreeing. The header is a list, so
|
|
362
|
+
// a page in English served as `en, fr` agrees with itself; only a header
|
|
363
|
+
// that does not mention the page's language at all is a contradiction.
|
|
364
|
+
// Compared by primary subtag, because en-GB and en are the same claim.
|
|
365
|
+
const header = res.headers?.get?.('content-language') ?? '';
|
|
366
|
+
const declared = header
|
|
367
|
+
.split(',')
|
|
368
|
+
.map((tag) => primaryLanguage(tag.trim()))
|
|
369
|
+
.filter(Boolean);
|
|
370
|
+
if (declared.length && !declared.includes(primaryLanguage(doc.lang))) {
|
|
371
|
+
out.push(f('warn', 'content-language-mismatch', 'The Content-Language header and <html lang> disagree',
|
|
372
|
+
`The header says "${header.trim()}" and the page says lang="${doc.lang}". One of them is wrong, ` +
|
|
373
|
+
'and which one a consumer believes is not something the page gets to decide.', url));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (!doc.viewport) out.push(f('error', 'viewport-missing', 'No viewport meta', 'The page will not render correctly on phones.', url));
|
|
377
|
+
else {
|
|
378
|
+
// The tag is there, so nothing reports it — but what it *says* is read by
|
|
379
|
+
// nobody until a phone renders the page. Two of its settings are checked
|
|
380
|
+
// here, both facts rather than preferences.
|
|
381
|
+
const viewport = Object.fromEntries(
|
|
382
|
+
doc.viewport.split(/[;,]/).map((part) => {
|
|
383
|
+
const [k, v] = part.split('=');
|
|
384
|
+
return [k?.trim().toLowerCase() ?? '', v?.trim().toLowerCase() ?? ''];
|
|
385
|
+
}),
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
// Pinch-to-zoom, switched off. WCAG 1.4.4 asks for text to reach 200%, and
|
|
389
|
+
// a maximum-scale under 2 forbids exactly that. iOS has ignored the tag
|
|
390
|
+
// since Safari 10, which is why the mistake survives: it is invisible to
|
|
391
|
+
// whoever tested it on their own phone, and Android honours it.
|
|
392
|
+
const maxScale = Number.parseFloat(viewport['maximum-scale']);
|
|
393
|
+
const locked = ['no', '0'].includes(viewport['user-scalable']);
|
|
394
|
+
if (locked || (Number.isFinite(maxScale) && maxScale < 2)) {
|
|
395
|
+
out.push(f('warn', 'viewport-locked', 'Viewport blocks zooming',
|
|
396
|
+
`"${doc.viewport}" — ${locked ? 'user-scalable is off' : `maximum-scale is ${maxScale}`}, so text ` +
|
|
397
|
+
'cannot be enlarged to the 200% WCAG 1.4.4 asks for. Safari has ignored this since iOS 10, so it ' +
|
|
398
|
+
'looks fine on an iPhone and blocks zoom everywhere else.', url));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// A pixel width is a desktop layout announced to a phone: the browser lays
|
|
402
|
+
// the page out that wide and scales the result down. Google indexes what
|
|
403
|
+
// the mobile crawler renders, and that is the shrunken version.
|
|
404
|
+
const width = viewport.width;
|
|
405
|
+
if (width && width !== 'device-width' && /^\d+$/.test(width)) {
|
|
406
|
+
out.push(f('warn', 'viewport-fixed-width', 'Viewport declares a fixed width',
|
|
407
|
+
`"${doc.viewport}" — width=${width} lays the page out ${width}px wide on every phone and scales ` +
|
|
408
|
+
'it down to fit. width=device-width is what makes a responsive layout responsive.', url));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// --- Canonical ----------------------------------------------------------
|
|
413
|
+
if (doc.canonical.length === 0) {
|
|
414
|
+
out.push(f('warn', 'canonical-missing', 'No canonical link', 'Duplicate URLs will compete with each other.', url));
|
|
415
|
+
} else if (doc.canonical.length > 1) {
|
|
416
|
+
out.push(f('error', 'canonical-multiple', 'Several canonical links',
|
|
417
|
+
`Google ignores all of them when they conflict: ${doc.canonical.join(', ')}`, url));
|
|
418
|
+
} else if (doc.canonical[0].replace(/\/$/, '') !== url.replace(/\/$/, '')) {
|
|
419
|
+
const paginated = paginatedCanonical(url, doc.canonical[0]);
|
|
420
|
+
if (paginated) {
|
|
421
|
+
out.push(paginated);
|
|
422
|
+
} else {
|
|
423
|
+
out.push(f('info', 'canonical-other', 'Canonical points elsewhere',
|
|
424
|
+
`→ ${doc.canonical[0]} (deliberate for a duplicate, a problem otherwise)`, url));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// --- Social -------------------------------------------------------------
|
|
429
|
+
for (const tag of ['og:title', 'og:description', 'og:image']) {
|
|
430
|
+
if (!doc.og[tag]) out.push(f('warn', 'og-missing', `Missing ${tag}`,
|
|
431
|
+
'Shared links will preview with whatever the platform scrapes.', url));
|
|
432
|
+
}
|
|
433
|
+
const ogImage = doc.og['og:image'];
|
|
434
|
+
// The Open Graph spec requires an absolute URL. A scraper has no page context
|
|
435
|
+
// to resolve `/og.jpg` against, so the preview comes out blank — and the
|
|
436
|
+
// markup looks perfectly reasonable to anyone reading it. Protocol-relative
|
|
437
|
+
// is tolerated here because scrapers do in fact resolve it.
|
|
438
|
+
if (ogImage && !/^(https?:)?\/\//i.test(ogImage)) {
|
|
439
|
+
out.push(f('error', 'og-image-relative', 'og:image is not an absolute URL',
|
|
440
|
+
`"${ogImage}" — Open Graph requires a full URL including the host. Shared links will preview blank.`, url));
|
|
441
|
+
}
|
|
442
|
+
if (ogImage && /\.webp($|\?)/i.test(ogImage)) {
|
|
443
|
+
out.push(f('warn', 'og-webp', 'og:image is WebP',
|
|
444
|
+
'LinkedIn does not render WebP previews and WhatsApp is unreliable with it. Use JPEG or PNG.', url));
|
|
445
|
+
}
|
|
446
|
+
if (ogImage && !doc.og['og:image:width']) {
|
|
447
|
+
out.push(f('info', 'og-no-dimensions', 'og:image has no declared width/height',
|
|
448
|
+
'Scrapers guess, and some skip the preview rather than guess.', url));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// --- hreflang -----------------------------------------------------------
|
|
452
|
+
// Reciprocity is checked across pages, in crossPageChecks. What is checkable
|
|
453
|
+
// from the page alone is whether the annotation is well formed and whether
|
|
454
|
+
// the page agrees with it about what the page is.
|
|
455
|
+
if (doc.hreflang.length) {
|
|
456
|
+
const malformed = doc.hreflang.filter((alt) => !isLanguageTag(alt.lang));
|
|
457
|
+
for (const alt of malformed) {
|
|
458
|
+
out.push(f('error', 'hreflang-invalid', `Malformed hreflang code: "${alt.lang}"`,
|
|
459
|
+
'Google ignores an annotation it cannot parse, so this version is invisible to it. The form is ' +
|
|
460
|
+
'a language, optionally a script and a region, joined by hyphens — en, en-GB, zh-Hant-TW. ' +
|
|
461
|
+
'An underscore instead of a hyphen is the usual cause.', url));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Every version has to list itself alongside the others, or the set is
|
|
465
|
+
// incomplete and Google may discard all of it.
|
|
466
|
+
const self = doc.hreflang.find((alt) => alt.href && withoutSlash(alt.href) === withoutSlash(url));
|
|
467
|
+
if (!self) {
|
|
468
|
+
out.push(f('warn', 'hreflang-no-self', 'hreflang does not list this page',
|
|
469
|
+
`It points at ${doc.hreflang.map((a) => a.lang).join(', ')} but never at itself. A version that ` +
|
|
470
|
+
'omits its own self-reference leaves the set incomplete.', url));
|
|
471
|
+
} else if (doc.lang && primaryLanguage(self.lang) !== primaryLanguage(doc.lang)) {
|
|
472
|
+
// The page's two statements about its own language, disagreeing. This is
|
|
473
|
+
// only ever visible on a translated page, which is the kind of page a
|
|
474
|
+
// homepage grader never opens.
|
|
475
|
+
out.push(f('warn', 'hreflang-lang-mismatch', 'The page disagrees with its own hreflang about its language',
|
|
476
|
+
`<html lang="${doc.lang}"> but hreflang calls this page "${self.lang}". Google reads both, and one ` +
|
|
477
|
+
'of them is wrong — usually a template that hardcodes lang while the annotation is generated.', url));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// --- Structured data ----------------------------------------------------
|
|
482
|
+
for (const block of doc.jsonld) {
|
|
483
|
+
if (!block.ok) {
|
|
484
|
+
out.push(f('error', 'jsonld-invalid', 'Structured data is not valid JSON', block.error, url));
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const data = block.data;
|
|
488
|
+
const hasType = (n) => n && (n['@type'] || Array.isArray(n['@graph']));
|
|
489
|
+
if (!hasType(data)) {
|
|
490
|
+
out.push(f('warn', 'jsonld-no-type', 'Structured data has no @type',
|
|
491
|
+
'A block without a type tells Google nothing.', url));
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// The type is declared and the JSON parses, and the rich result still never
|
|
496
|
+
// appears because a property Google requires is not there.
|
|
497
|
+
const shortfalls = [];
|
|
498
|
+
for (const node of schemaNodes(doc.jsonld)) {
|
|
499
|
+
for (const type of [node['@type']].flat().filter((t) => typeof t === 'string')) {
|
|
500
|
+
const missing = (SCHEMA_REQUIRED[type] ?? []).filter((p) => !present(node[p]));
|
|
501
|
+
if (type === 'Product') {
|
|
502
|
+
if (!present(node.name)) missing.push('name');
|
|
503
|
+
if (!PRODUCT_ONE_OF.some((p) => present(node[p]))) {
|
|
504
|
+
missing.push(`one of ${PRODUCT_ONE_OF.join(', ')}`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (missing.length) shortfalls.push(`${type} is missing ${missing.join(' and ')}`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
// Dates that contradict themselves. Structured data is where Google reads
|
|
511
|
+
// when a page was written and when it last changed, and it shows the result
|
|
512
|
+
// in the listing — so a page modified before it was published, or updated
|
|
513
|
+
// next Tuesday, is a claim about freshness that cannot be true.
|
|
514
|
+
//
|
|
515
|
+
// A day of slack on the future, the same allowance the sitemap's lastmod
|
|
516
|
+
// check makes: a build stamping "now" on a machine with a skewed clock is
|
|
517
|
+
// not the problem being described.
|
|
518
|
+
const dated = schemaNodes(doc.jsonld).filter((n) => present(n.datePublished) || present(n.dateModified));
|
|
519
|
+
const when = (value) => {
|
|
520
|
+
const at = Date.parse(Array.isArray(value) ? value[0] : value);
|
|
521
|
+
return Number.isFinite(at) ? at : null;
|
|
522
|
+
};
|
|
523
|
+
// A minute of slack. Shopify's theme writes datePublished and dateModified
|
|
524
|
+
// from timestamps that can round apart by a second: eleven of one store's
|
|
525
|
+
// twelve inversions were exactly 1s, and the twelfth was nine hours. The
|
|
526
|
+
// one-second class is a generator artifact nobody can act on, and reporting
|
|
527
|
+
// it would bury the one that means something.
|
|
528
|
+
const backwards = dated.filter((n) => {
|
|
529
|
+
const published = when(n.datePublished);
|
|
530
|
+
const modified = when(n.dateModified);
|
|
531
|
+
return published !== null && modified !== null && published - modified > MINUTE;
|
|
532
|
+
});
|
|
533
|
+
if (backwards.length) {
|
|
534
|
+
const node = backwards[0];
|
|
535
|
+
out.push(f('warn', 'schema-date-order', 'Structured data says the page was modified before it was published',
|
|
536
|
+
`${node['@type']}: datePublished ${node.datePublished}, dateModified ${node.dateModified}. One of ` +
|
|
537
|
+
'the two is wrong, and Google reads both when deciding how fresh this page is.', url));
|
|
538
|
+
}
|
|
539
|
+
const ahead = dated.filter((n) =>
|
|
540
|
+
[n.datePublished, n.dateModified].some((value) => {
|
|
541
|
+
const at = when(value);
|
|
542
|
+
return at !== null && at > (limits.now ?? Date.now()) + DAY;
|
|
543
|
+
}));
|
|
544
|
+
if (ahead.length) {
|
|
545
|
+
const node = ahead[0];
|
|
546
|
+
out.push(f('warn', 'schema-date-future', 'Structured data carries a date that has not happened yet',
|
|
547
|
+
`${node['@type']}: ${[['datePublished', node.datePublished], ['dateModified', node.dateModified]]
|
|
548
|
+
.filter(([, v]) => when(v) !== null && when(v) > (limits.now ?? Date.now()) + DAY)
|
|
549
|
+
.map(([k, v]) => `${k} ${v}`)
|
|
550
|
+
.join(', ')}. A date in the future is not a freshness signal a crawler can use, and it is usually ` +
|
|
551
|
+
'a timezone or a scheduling bug in the generator.', url));
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (shortfalls.length) {
|
|
555
|
+
out.push(f('warn', 'schema-incomplete', 'Structured data is missing a property Google requires',
|
|
556
|
+
`${[...new Set(shortfalls)].join('; ')}. The markup is valid and the type is right, so nothing ` +
|
|
557
|
+
'reports an error — the rich result simply never appears.', url));
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// --- Images -------------------------------------------------------------
|
|
561
|
+
// role="presentation" (or "none") declares an image decorative in ARIA, which
|
|
562
|
+
// is the same statement alt="" makes and is honoured by screen readers.
|
|
563
|
+
// alt="" is still the more robust way to say it, but this is a deliberate
|
|
564
|
+
// choice rather than an oversight — mozilla.org's accessibility team ships it
|
|
565
|
+
// — and calling a deliberate choice an error is how a report gets ignored.
|
|
566
|
+
const decorativeByRole = (i) => /^(presentation|none)$/i.test(i.role ?? '');
|
|
567
|
+
const noAlt = doc.images.filter(
|
|
568
|
+
(i) => i.alt === null && !decorativeByRole(i) && !i.altBound,
|
|
569
|
+
);
|
|
570
|
+
if (noAlt.length) {
|
|
571
|
+
// An <img> can have no src either — a lazy-loading placeholder, or markup
|
|
572
|
+
// waiting on JavaScript. Saying "First: null" helped nobody find it.
|
|
573
|
+
const where = noAlt[0].src ?? 'an <img> with no src attribute either';
|
|
574
|
+
out.push(f('error', 'img-alt', `${noAlt.length} image(s) with no alt attribute`,
|
|
575
|
+
`First: ${where}. Decorative images need alt="" — the attribute must exist either way.`, url));
|
|
576
|
+
}
|
|
577
|
+
// Alt text that exists but says nothing. alt="" is deliberate and correct for
|
|
578
|
+
// a decorative image, so it is never judged here — only text a screen reader
|
|
579
|
+
// would actually read out.
|
|
580
|
+
const described = doc.images.filter((i) => i.alt);
|
|
581
|
+
const norm = (i) => i.alt.trim().toLowerCase().replace(/[.:,;!?—–-]+$/, '');
|
|
582
|
+
|
|
583
|
+
const filename = described.filter((i) => ALT_FILENAME.test(i.alt.trim()) || ALT_SERIAL.test(i.alt.trim()));
|
|
584
|
+
if (filename.length) {
|
|
585
|
+
out.push(f('warn', 'img-alt-filename', `${filename.length} image(s) with a filename as alt text`,
|
|
586
|
+
`First: alt="${filename[0].alt}" on ${filename[0].src}. That is what a CMS fills in when nobody typed anything — it describes the file, not the picture.`, url));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const placeholder = described.filter((i) => ALT_PLACEHOLDER.has(norm(i)));
|
|
590
|
+
if (placeholder.length) {
|
|
591
|
+
out.push(f('warn', 'img-alt-placeholder', `${placeholder.length} image(s) with placeholder alt text`,
|
|
592
|
+
`First: alt="${placeholder[0].alt}" on ${placeholder[0].src}. It names the medium, not the content — a screen reader already announces "image" before reading it.`, url));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Counted over what is left, because a filename or a placeholder repeated on
|
|
596
|
+
// every image is already reported above, with a more useful message.
|
|
597
|
+
const flagged = new Set([...filename, ...placeholder]);
|
|
598
|
+
const repeats = new Map();
|
|
599
|
+
for (const i of described.filter((i) => !flagged.has(i))) {
|
|
600
|
+
repeats.set(norm(i), [...(repeats.get(norm(i)) ?? []), i]);
|
|
601
|
+
}
|
|
602
|
+
for (const [alt, group] of repeats) {
|
|
603
|
+
if (group.length >= ALT_DUP_MIN) {
|
|
604
|
+
out.push(f('info', 'img-alt-duplicate', `${group.length} images share one alt text`,
|
|
605
|
+
`"${alt}" — fair for near-identical product shots, a template nobody filled in otherwise. Each image earns its own description.`, url));
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// The `title` attribute is never reported for being absent — an image without
|
|
610
|
+
// one has nothing wrong with it, it is a hover tooltip that touch devices
|
|
611
|
+
// cannot show and Google does not read. What is worth saying is when it
|
|
612
|
+
// contradicts something else on the same tag.
|
|
613
|
+
const titledSameAsAlt = doc.images.filter(
|
|
614
|
+
(i) => i.title && i.alt && i.title.trim() === i.alt.trim(),
|
|
615
|
+
);
|
|
616
|
+
if (titledSameAsAlt.length) {
|
|
617
|
+
out.push(f('info', 'img-title-duplicates-alt', `${titledSameAsAlt.length} image(s) repeat the alt text as a title`,
|
|
618
|
+
`First: "${titledSameAsAlt[0].title}" on ${titledSameAsAlt[0].src}. One field filling both is the usual ` +
|
|
619
|
+
'cause. It adds nothing for a sighted visitor and a screen reader that surfaces both reads it twice.', url));
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const titledDecorative = doc.images.filter(
|
|
623
|
+
(i) => i.title && (i.alt === '' || decorativeByRole(i)),
|
|
624
|
+
);
|
|
625
|
+
if (titledDecorative.length) {
|
|
626
|
+
out.push(f('info', 'img-title-on-decorative', `${titledDecorative.length} decorative image(s) carry a title`,
|
|
627
|
+
`First: "${titledDecorative[0].title}" on ${titledDecorative[0].src}. The markup declares the image ` +
|
|
628
|
+
'decorative and then attaches a tooltip to it — one of the two is wrong.', url));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Told to wait and told to hurry. `fetchpriority="high"` says this image
|
|
632
|
+
// matters enough to fetch before the others; `loading="lazy"` says do not
|
|
633
|
+
// fetch it until it is nearly on screen, and that one decides when. The
|
|
634
|
+
// priority has almost nothing left to act on.
|
|
635
|
+
//
|
|
636
|
+
// `info`, not a warning, because it is defensible: a browser does apply the
|
|
637
|
+
// priority once a lazy image finally enters the queue. What it cannot be is
|
|
638
|
+
// deliberate on the image that matters most, which is the only image
|
|
639
|
+
// fetchpriority is usually put on.
|
|
640
|
+
const hurriedAndDeferred = doc.images.filter(
|
|
641
|
+
(i) => /^lazy$/i.test(i.loading ?? '') && /^high$/i.test(i.fetchpriority ?? ''),
|
|
642
|
+
);
|
|
643
|
+
if (hurriedAndDeferred.length) {
|
|
644
|
+
out.push(f('info', 'img-lazy-priority', `${hurriedAndDeferred.length} image(s) are both deferred and prioritised`,
|
|
645
|
+
`First: ${hurriedAndDeferred[0].src}. loading="lazy" and fetchpriority="high" on one element ask ` +
|
|
646
|
+
'for opposite things, and lazy decides when the request happens. If this is the image the page ' +
|
|
647
|
+
'is judged on, drop the lazy; if it is not, drop the priority.', url));
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const longAlt = described.filter((i) => i.alt.length > ALT_MAX);
|
|
651
|
+
if (longAlt.length) {
|
|
652
|
+
out.push(f('info', 'img-alt-long', `${longAlt.length} image(s) with very long alt text`,
|
|
653
|
+
`First: ${longAlt[0].alt.length} chars on ${longAlt[0].src}. Alt is read in one breath, with no way to skim — a description this long belongs in the page text, where everyone gets it.`, url));
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const noDim = doc.images.filter((i) => i.src && (!i.width || !i.height));
|
|
657
|
+
if (noDim.length) {
|
|
658
|
+
out.push(f('warn', 'img-dimensions', `${noDim.length} image(s) without width/height`,
|
|
659
|
+
`First: ${noDim[0].src}. Without them the page reflows as images arrive (layout shift).`, url));
|
|
660
|
+
}
|
|
661
|
+
const noSrcset = doc.images.filter((i) => i.src && !i.srcset && !i.inPicture && !/\.svg($|\?)/i.test(i.src));
|
|
662
|
+
if (noSrcset.length) {
|
|
663
|
+
out.push(f('info', 'img-srcset', `${noSrcset.length} image(s) served at one size`,
|
|
664
|
+
`First: ${noSrcset[0].src}. A phone downloads the desktop file.`, url));
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// --- Content ------------------------------------------------------------
|
|
668
|
+
if (doc.words < LIMITS.thinWords) {
|
|
669
|
+
out.push(f('warn', 'thin-content', 'Thin page',
|
|
670
|
+
`${doc.words} words. Under ~${LIMITS.thinWords} rarely ranks for anything competitive.`, url));
|
|
671
|
+
}
|
|
672
|
+
// A nofollow on an internal link is a page telling Google not to walk its own
|
|
673
|
+
// site. Sometimes deliberate — a login or a faceted filter nobody wants
|
|
674
|
+
// crawled — so a note, not a complaint.
|
|
675
|
+
const nofollowed = doc.links.nofollowInternal ?? [];
|
|
676
|
+
if (nofollowed.length) {
|
|
677
|
+
out.push(f('info', 'internal-nofollow', `${nofollowed.length} internal link(s) marked nofollow`,
|
|
678
|
+
`First: ${nofollowed.slice(0, 3).join(', ')}. Fair for a login or a filter nobody should crawl; ` +
|
|
679
|
+
'on an ordinary page it withholds a path through your own site for no gain.', url));
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (doc.links.inMain.length === 0) {
|
|
683
|
+
out.push(f('info', 'no-editorial-links', 'No links inside the content',
|
|
684
|
+
'Only navigation links out of this page — nothing passes authority to related pages.', url));
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// --- Heading order ------------------------------------------------------
|
|
688
|
+
// A jump from h1 to h3 is how a screen-reader user loses the shape of a
|
|
689
|
+
// page, and how a search engine loses the outline of the argument.
|
|
690
|
+
const levels = doc.headingLevels ?? [];
|
|
691
|
+
for (let i = 1; i < levels.length; i++) {
|
|
692
|
+
if (levels[i] - levels[i - 1] > 1) {
|
|
693
|
+
out.push(f('warn', 'heading-skip', `Heading level jumps from h${levels[i - 1]} to h${levels[i]}`,
|
|
694
|
+
'Headings should descend one level at a time — the outline is a structure, not a size chart.', url));
|
|
695
|
+
break;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// --- URL hygiene --------------------------------------------------------
|
|
700
|
+
const path = new URL(url).pathname;
|
|
701
|
+
if (/[A-Z]/.test(path)) {
|
|
702
|
+
out.push(f('warn', 'url-uppercase', 'URL contains uppercase letters',
|
|
703
|
+
`${path} — servers usually treat case as significant, so this invites duplicate URLs.`, url));
|
|
704
|
+
}
|
|
705
|
+
if (path.includes('_')) {
|
|
706
|
+
out.push(f('info', 'url-underscore', 'URL uses underscores',
|
|
707
|
+
`${path} — Google reads hyphens as word separators and underscores as joins.`, url));
|
|
708
|
+
}
|
|
709
|
+
if (/%20|\s/.test(path)) {
|
|
710
|
+
out.push(f('warn', 'url-space', 'URL contains spaces', path, url));
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// --- Head essentials ----------------------------------------------------
|
|
714
|
+
if (doc.charset === null) {
|
|
715
|
+
out.push(f('warn', 'charset-missing', 'No character encoding declared',
|
|
716
|
+
'Without it the browser guesses, and guesses wrongly on non-Latin text.', url));
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// --- Mixed content ------------------------------------------------------
|
|
720
|
+
// Only things the page *loads*. A browser blocks an http:// script and warns
|
|
721
|
+
// about an http:// image; it does nothing whatever about <a href="http://…">,
|
|
722
|
+
// which is an ordinary link to somebody else's site — usually one the author
|
|
723
|
+
// does not control and cannot upgrade. Matching every href reported four
|
|
724
|
+
// errors across five real sites, all of them outbound links and a feed.
|
|
725
|
+
if (url.startsWith('https://')) {
|
|
726
|
+
const insecure = [];
|
|
727
|
+
// Same reason as in parseHtml: a code sample stored in an attribute is not
|
|
728
|
+
// a resource this page loads.
|
|
729
|
+
for (const match of stripMarkupInAttributes(page.html ?? '').matchAll(/<([a-z0-9-]+)\b[^>]*>/gi)) {
|
|
730
|
+
const [tag, name] = [match[0], match[1].toLowerCase()];
|
|
731
|
+
let loaded = null;
|
|
732
|
+
if (SUBRESOURCE.test(name)) loaded = attr(tag, 'src');
|
|
733
|
+
// A stylesheet is the one <link> fetched to render the page. rel=alternate
|
|
734
|
+
// on a feed, and rel=canonical, are not fetched at all.
|
|
735
|
+
else if (name === 'link' && /\bstylesheet\b/i.test(attr(tag, 'rel') ?? '')) {
|
|
736
|
+
loaded = attr(tag, 'href');
|
|
737
|
+
}
|
|
738
|
+
if (loaded?.startsWith('http://') && !loaded.startsWith('http://localhost')) {
|
|
739
|
+
insecure.push(loaded);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
if (insecure.length) {
|
|
743
|
+
out.push(f('error', 'mixed-content', 'Insecure resources on an HTTPS page',
|
|
744
|
+
`${insecure.length}, first: ${insecure[0]}. Browsers block or refuse to render these.`, url));
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Not an estimate, which is the line this tool does not cross: whether the
|
|
749
|
+
// response arrived compressed is a header, and how much HTML came back is a
|
|
750
|
+
// byte count. Neither is a guess about how the page renders.
|
|
751
|
+
//
|
|
752
|
+
// Only for documents worth compressing — a CDN skipping a 900-byte response
|
|
753
|
+
// is doing the right thing, and reporting it would be noise.
|
|
754
|
+
const bytes = (page.html ?? '').length;
|
|
755
|
+
if (!res.headers?.get?.('content-encoding') && bytes > COMPRESSIBLE_FROM) {
|
|
756
|
+
out.push(f('warn', 'uncompressed', 'HTML is served without compression',
|
|
757
|
+
`${(bytes / 1024).toFixed(0)}KB of HTML and no content-encoding. Gzip or Brotli typically takes ` +
|
|
758
|
+
'markup to a quarter of this, and it is a server setting rather than a change to the page.', url));
|
|
759
|
+
}
|
|
760
|
+
if (bytes > HUGE_HTML) {
|
|
761
|
+
out.push(f('info', 'huge-html', 'Very large HTML document',
|
|
762
|
+
`${(bytes / 1024 / 1024).toFixed(1)}MB before any images or scripts. Not a limit — Google reads far ` +
|
|
763
|
+
'more than this — but a document this size is usually a template inlining something it should link to.',
|
|
764
|
+
url));
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (res.ms > LIMITS.slowMs) {
|
|
768
|
+
out.push(f('info', 'slow', 'Slow response',
|
|
769
|
+
`${res.ms}ms to first byte from this machine. Measure properly with WebPageTest.`, url));
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
return out;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Elements whose src the browser fetches as part of rendering the page. These
|
|
776
|
+
// are what "mixed content" means; a hyperlink is not one of them.
|
|
777
|
+
const SUBRESOURCE = /^(img|script|iframe|video|audio|source|embed|track|input|object)$/;
|
|
778
|
+
|
|
779
|
+
const MINUTE = 60 * 1000;
|
|
780
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
781
|
+
|
|
782
|
+
// Below this, "every page shares a date" is a coincidence rather than a
|
|
783
|
+
// pattern — a five-page brochure site genuinely does get rebuilt all at once.
|
|
784
|
+
const LASTMOD_SAMPLE = 5;
|
|
785
|
+
|
|
786
|
+
/** Checks on the sitemap itself, rather than the pages it lists.
|
|
787
|
+
*
|
|
788
|
+
* `now` is injectable so the tests are not hostage to the clock. */
|
|
789
|
+
// The protocol's hard limits, per file. Past either, a crawler is entitled to
|
|
790
|
+
// reject the whole sitemap rather than read part of it.
|
|
791
|
+
const SITEMAP_MAX_URLS = 50_000;
|
|
792
|
+
const SITEMAP_MAX_BYTES = 50 * 1024 * 1024;
|
|
793
|
+
|
|
794
|
+
export function sitemapChecks(entries, source, now = Date.now(), files = []) {
|
|
795
|
+
const out = [];
|
|
796
|
+
|
|
797
|
+
for (const file of files) {
|
|
798
|
+
if (file.urls > SITEMAP_MAX_URLS) {
|
|
799
|
+
out.push(f('error', 'sitemap-too-many-urls', `A sitemap file lists ${file.urls.toLocaleString()} URLs`,
|
|
800
|
+
`${file.url} — the protocol allows ${SITEMAP_MAX_URLS.toLocaleString()} per file, and past that a ` +
|
|
801
|
+
'crawler may reject the whole file rather than read part of it. Split it and list the parts in a ' +
|
|
802
|
+
'sitemap index.', file.url));
|
|
803
|
+
}
|
|
804
|
+
if (file.bytes > SITEMAP_MAX_BYTES) {
|
|
805
|
+
out.push(f('error', 'sitemap-too-large', `A sitemap file is ${(file.bytes / 1024 / 1024).toFixed(1)}MB`,
|
|
806
|
+
`${file.url} — the protocol allows 50MB uncompressed per file. Split it, or serve it gzipped.`,
|
|
807
|
+
file.url));
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// The same URL listed twice. Within one file it is a generator emitting a
|
|
812
|
+
// page from two rules; across the files of an index it is two rules that do
|
|
813
|
+
// not know about each other — a blog sitemap and a category sitemap both
|
|
814
|
+
// claiming the same post. Google will pick one and move on, so this costs
|
|
815
|
+
// crawl budget and clarity rather than rankings, which is why it is a note.
|
|
816
|
+
//
|
|
817
|
+
// An image or video sitemap is skipped, and recognised by its shape rather
|
|
818
|
+
// than by its name or its namespace. Yoast declares xmlns:image on every
|
|
819
|
+
// file it writes, so the namespace proves nothing: css-tricks.com's
|
|
820
|
+
// post-sitemap2.xml carries image elements and is a perfectly ordinary list
|
|
821
|
+
// of posts. What an extension sitemap actually looks like is one entry per
|
|
822
|
+
// image — wordpress.org's image-sitemap-1.xml has 681 entries for 28 pages,
|
|
823
|
+
// repeating `/` more than forty times. A file whose locs are mostly repeats
|
|
824
|
+
// is not listing pages, so it is not compared.
|
|
825
|
+
const listsPages = (file) => {
|
|
826
|
+
const locs = file.locs ?? [];
|
|
827
|
+
return locs.length < 2 || new Set(locs).size > locs.length / 2;
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
const where = new Map();
|
|
831
|
+
for (const file of files.filter(listsPages)) {
|
|
832
|
+
for (const loc of file.locs ?? []) {
|
|
833
|
+
const seen = where.get(loc) ?? new Set();
|
|
834
|
+
seen.add(file.url);
|
|
835
|
+
where.set(loc, seen);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
const listedTwice = [...where].filter(([loc, inFiles]) => {
|
|
839
|
+
const total = files
|
|
840
|
+
.filter(listsPages)
|
|
841
|
+
.reduce((n, file) => n + (file.locs ?? []).filter((l) => l === loc).length, 0);
|
|
842
|
+
return total > 1 || inFiles.size > 1;
|
|
843
|
+
});
|
|
844
|
+
if (listedTwice.length) {
|
|
845
|
+
const [loc, inFiles] = listedTwice[0];
|
|
846
|
+
out.push(f('info', 'sitemap-duplicate-url', `${listedTwice.length} URL(s) are listed more than once`,
|
|
847
|
+
`First: ${loc}${inFiles.size > 1 ? `, in ${[...inFiles].join(' and ')}` : ' — twice in one file'}. ` +
|
|
848
|
+
'A sitemap is a list of the pages you want indexed, and listing one twice says nothing extra ' +
|
|
849
|
+
'while making the file harder to trust.', source ?? loc));
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (!entries.length) return out;
|
|
853
|
+
|
|
854
|
+
const dated = entries.filter((entry) => entry.lastmod);
|
|
855
|
+
if (!dated.length) {
|
|
856
|
+
out.push(f('info', 'sitemap-lastmod-missing', 'No page in the sitemap declares a lastmod',
|
|
857
|
+
'Crawlers use it to decide what to look at again. Without it, a large site is re-crawled on ' +
|
|
858
|
+
'guesswork, and a page that changed waits its turn behind pages that did not.', source));
|
|
859
|
+
return out;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// A day of slack, because a build stamping "now" on a machine with a skewed
|
|
863
|
+
// clock is not the problem being described here.
|
|
864
|
+
const future = dated.filter((entry) => {
|
|
865
|
+
const at = Date.parse(entry.lastmod);
|
|
866
|
+
return Number.isFinite(at) && at > now + DAY;
|
|
867
|
+
});
|
|
868
|
+
if (future.length) {
|
|
869
|
+
out.push(f('warn', 'sitemap-lastmod-future', `${future.length} page(s) claim a lastmod in the future`,
|
|
870
|
+
`First: ${future[0].loc} says ${future[0].lastmod}. A date that has not happened yet is not a ` +
|
|
871
|
+
'signal a crawler can use, and it is usually a timezone or a scheduling bug in the generator.', source));
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// The interesting failure: a generator stamping build time on every URL. It
|
|
875
|
+
// looks diligent and is worth nothing, because Google learns the dates never
|
|
876
|
+
// distinguish one page from another and stops reading them.
|
|
877
|
+
const distinct = new Set(dated.map((entry) => entry.lastmod));
|
|
878
|
+
if (dated.length >= LASTMOD_SAMPLE && distinct.size === 1) {
|
|
879
|
+
out.push(f('info', 'sitemap-lastmod-identical', 'Every page in the sitemap has the same lastmod',
|
|
880
|
+
`All ${dated.length} say ${dated[0].lastmod}. That is a generator stamping build time rather than ` +
|
|
881
|
+
'when each page last changed — which tells a crawler nothing, so it learns to ignore the field.', source));
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
return out;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** Checks that need every page at once. */
|
|
888
|
+
export function crossPageChecks(pages, opts = {}) {
|
|
889
|
+
const LIMITS = { ...DEFAULT_LIMITS, ...(opts.limits ?? {}) };
|
|
890
|
+
const out = [];
|
|
891
|
+
const live = pages.filter((p) => p.doc && p.res.ok);
|
|
892
|
+
|
|
893
|
+
const groupBy = (key) => {
|
|
894
|
+
const map = new Map();
|
|
895
|
+
for (const p of live) {
|
|
896
|
+
const value = p.doc[key];
|
|
897
|
+
if (!value) continue;
|
|
898
|
+
map.set(value, [...(map.get(value) ?? []), p.url]);
|
|
899
|
+
}
|
|
900
|
+
return [...map].filter(([, urls]) => urls.length > 1);
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
for (const [title, urls] of groupBy('title')) {
|
|
904
|
+
out.push(f('warn', 'duplicate-title', 'Same title on several pages',
|
|
905
|
+
`"${title}" — ${urls.length} pages: ${urls.slice(0, 4).join(', ')}`, urls[0]));
|
|
906
|
+
}
|
|
907
|
+
for (const [desc, urls] of groupBy('description')) {
|
|
908
|
+
out.push(f('warn', 'duplicate-description', 'Same meta description on several pages',
|
|
909
|
+
`${urls.length} pages: ${urls.slice(0, 4).join(', ')}`, urls[0]));
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// --- The same page again ------------------------------------------------
|
|
913
|
+
// Titles and descriptions have been compared for a long time; the bodies
|
|
914
|
+
// never were, and that is the axis a hundred product pages differ on by one
|
|
915
|
+
// word. They compete with each other for one result and spend the crawl
|
|
916
|
+
// budget that would have gone to the pages that are actually different.
|
|
917
|
+
//
|
|
918
|
+
// Three narrowings, because this is exactly the shape of check that cries
|
|
919
|
+
// wolf. A page that says noindex is not in the index to be duplicated in.
|
|
920
|
+
// A page whose canonical points somewhere else has already declared itself a
|
|
921
|
+
// copy — that is the fix, correctly applied, and reporting it would be
|
|
922
|
+
// reporting a solved problem. And a page without a marked content region has
|
|
923
|
+
// no comparable text at all.
|
|
924
|
+
const comparable = live.filter((p) => {
|
|
925
|
+
if (!p.doc.fingerprint) return false;
|
|
926
|
+
if (/noindex/i.test(p.doc.robots ?? '')) return false;
|
|
927
|
+
const canonical = p.doc.canonical?.[0];
|
|
928
|
+
return !canonical || canonical.replace(/\/$/, '') === p.url.replace(/\/$/, '');
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
for (const group of cluster(comparable.map((p) => ({ url: p.url, fingerprint: p.doc.fingerprint })))) {
|
|
932
|
+
const percent = Math.round(group.similarity * 100);
|
|
933
|
+
out.push(f('warn', 'duplicate-content', `${group.urls.length} pages are the same page again`,
|
|
934
|
+
`Their content is at least ${percent}% identical, and none of them says which one Google ` +
|
|
935
|
+
`should keep: ${group.urls.slice(0, 4).join(', ')}` +
|
|
936
|
+
(group.urls.length > 4 ? `, and ${group.urls.length - 4} more` : '') +
|
|
937
|
+
'. Give the copies a rel=canonical pointing at the one that should rank, or make them ' +
|
|
938
|
+
'different pages.',
|
|
939
|
+
group.urls[0]));
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// A missing finding reads exactly like a passing one, so the pages this
|
|
943
|
+
// could not look at are counted rather than passed over.
|
|
944
|
+
const uncomparable = live.filter((p) => !p.doc.fingerprint).length;
|
|
945
|
+
if (uncomparable > 0 && live.length > 1) {
|
|
946
|
+
out.push(f('info', 'duplicate-content-not-checked',
|
|
947
|
+
`${uncomparable} page(s) were not compared for duplicate content`,
|
|
948
|
+
'Content is compared inside <main> or <article>. Without one of those the text of a page is ' +
|
|
949
|
+
'the whole document, navigation and footer included, and every page of a small site would ' +
|
|
950
|
+
'look like a copy of every other. Pages under about a hundred words are skipped for the ' +
|
|
951
|
+
'same reason — at that length two pages share most of their words whatever they say.',
|
|
952
|
+
pages[0]?.url));
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Whether the link graph is worth reading at all. A page that was not
|
|
956
|
+
// fetched contributes no outgoing links, so everything it linked to looks
|
|
957
|
+
// unlinked; a crawl cut short by --limit does the same thing at scale. Both
|
|
958
|
+
// checks below stand down on it, because both answer questions about the
|
|
959
|
+
// whole graph from whatever fragment was collected.
|
|
960
|
+
//
|
|
961
|
+
// A Shopify store made the case: 200 of its 325 URLs crawled, 70 of those
|
|
962
|
+
// rate-limited away, and 122 pages reported as orphans in the same report
|
|
963
|
+
// that declined to measure click depth for exactly this reason. One check
|
|
964
|
+
// refusing to answer while its neighbour answers confidently from the same
|
|
965
|
+
// data is not a defensible position.
|
|
966
|
+
const unfetched = pages.length - live.length;
|
|
967
|
+
const partial =
|
|
968
|
+
opts.truncated > 0
|
|
969
|
+
? `the crawl stopped ${opts.truncated} page(s) short of the whole site`
|
|
970
|
+
: unfetched > pages.length * 0.1
|
|
971
|
+
? `${unfetched} of ${pages.length} crawled pages did not load`
|
|
972
|
+
: null;
|
|
973
|
+
|
|
974
|
+
const homeCrawled = live.find((p) => new URL(p.url).pathname.replace(/\/$/, '') === '');
|
|
975
|
+
const anchorUrl = homeCrawled?.url ?? live[0]?.url;
|
|
976
|
+
|
|
977
|
+
// A page nothing links to is a page Google reaches only because the sitemap
|
|
978
|
+
// mentions it — it inherits no internal authority and reads as an
|
|
979
|
+
// afterthought. Home is exempt: it is linked from outside, not from within.
|
|
980
|
+
if (partial && anchorUrl) {
|
|
981
|
+
out.push(f('info', 'orphan-check-skipped', 'Orphan pages were not looked for',
|
|
982
|
+
`A page is an orphan when nothing on the site links to it, and ${partial} — so the links that ` +
|
|
983
|
+
'would prove otherwise may simply not have been read. Every page in a fragment of a site looks ' +
|
|
984
|
+
'unlinked. Raise --limit, or lower --concurrency if the pages were refused, and run it again.',
|
|
985
|
+
anchorUrl));
|
|
986
|
+
}
|
|
987
|
+
// One graph, built once, read by the orphan check below, by click depth
|
|
988
|
+
// further down and by the report's ordering. It used to be built twice inside
|
|
989
|
+
// this function and thrown away, which let two checks disagree about the same
|
|
990
|
+
// site and left everything else with no access to it at all.
|
|
991
|
+
const graph = opts.graph ?? linkGraph(live, opts.home);
|
|
992
|
+
const linkedTo = new Set([...graph.inlinks.keys()]);
|
|
993
|
+
if (!partial) {
|
|
994
|
+
for (const p of live) {
|
|
995
|
+
const isHome = new URL(p.url).pathname.replace(/\/$/, '') === '';
|
|
996
|
+
if (!isHome && !linkedTo.has(graphKey(p.url))) {
|
|
997
|
+
out.push(f('warn', 'orphan-page', 'Nothing links to this page',
|
|
998
|
+
'It is in the sitemap, but no other page links to it — so it collects no internal authority.', p.url));
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// --- Click depth --------------------------------------------------------
|
|
1004
|
+
// How many links from the homepage a page actually is. An orphan is the
|
|
1005
|
+
// extreme of this — nothing links to it at all — and it was the only part of
|
|
1006
|
+
// the shape being reported. A page five clicks down has the same illness
|
|
1007
|
+
// milder: Google finds it late, crawls it rarely, and passes it almost
|
|
1008
|
+
// nothing, while every single-page grader calls it perfect.
|
|
1009
|
+
//
|
|
1010
|
+
// The graph is the one already built above, so this costs no requests.
|
|
1011
|
+
//
|
|
1012
|
+
// The root is the homepage, and a sitemap is under no obligation to list it:
|
|
1013
|
+
// eslint.org's names 499 URLs and not the one every visitor starts from. The
|
|
1014
|
+
// caller may hand one over for exactly that case; it is a root to measure
|
|
1015
|
+
// from, never a page to report on.
|
|
1016
|
+
const key = graphKey;
|
|
1017
|
+
const home = graph.root;
|
|
1018
|
+
if (home) {
|
|
1019
|
+
const { depth, from: cameFrom } = graph;
|
|
1020
|
+
|
|
1021
|
+
// Pages with no path from home at all. A few are a finding. A lot means the
|
|
1022
|
+
// navigation is built by JavaScript and this tool cannot see it — and then
|
|
1023
|
+
// every depth here is wrong, so none of them is worth printing. Google
|
|
1024
|
+
// renders, so it can follow those links; the honest report is that the
|
|
1025
|
+
// question was not answered, not a page of invented findings.
|
|
1026
|
+
const stranded = graph.stranded;
|
|
1027
|
+
const unreadable = live.length >= 5 && stranded.length > live.length * 0.3;
|
|
1028
|
+
|
|
1029
|
+
if (partial || unreadable) {
|
|
1030
|
+
out.push(f('info', 'click-depth-skipped', 'Click depth was not measured',
|
|
1031
|
+
partial
|
|
1032
|
+
? `Measured from the homepage over the links between crawled pages, and ${partial}, so those ` +
|
|
1033
|
+
'links are a fragment of the real graph. A distance measured across a fragment is not the ' +
|
|
1034
|
+
'distance, so it is not reported. Raise --limit to measure it.'
|
|
1035
|
+
: `${stranded.length} of ${live.length} crawled pages have no chain of links from the homepage ` +
|
|
1036
|
+
'reaching them, which is what a JavaScript-built navigation looks like to something that ' +
|
|
1037
|
+
'reads HTML. Google renders and can follow those links, so the depths here would be wrong ' +
|
|
1038
|
+
'rather than alarming, and are not reported.',
|
|
1039
|
+
home.url));
|
|
1040
|
+
} else {
|
|
1041
|
+
// Linked from somewhere, yet no route from the homepage — the page is
|
|
1042
|
+
// reachable only from another page that is itself unreachable. Orphans
|
|
1043
|
+
// are excluded: nothing links to those, which is already reported, and
|
|
1044
|
+
// saying it twice about one page helps nobody.
|
|
1045
|
+
for (const p of stranded) {
|
|
1046
|
+
if (!linkedTo.has(key(p.url))) continue;
|
|
1047
|
+
out.push(f('warn', 'no-path-from-home', 'No path from the homepage to this page',
|
|
1048
|
+
'Something links to it, but no chain of links starting at the homepage arrives — it hangs off ' +
|
|
1049
|
+
'a page that is itself unreachable. A crawler that has not been handed the sitemap never ' +
|
|
1050
|
+
'finds it, and it inherits nothing from the pages that rank.', p.url));
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const routeTo = (k) => {
|
|
1054
|
+
const hops = [];
|
|
1055
|
+
for (let at = k; at !== undefined; at = cameFrom.get(at)) hops.unshift(new URL(at).pathname || '/');
|
|
1056
|
+
return hops.join(' → ');
|
|
1057
|
+
};
|
|
1058
|
+
const deep = live
|
|
1059
|
+
.filter((p) => depth.get(key(p.url)) > LIMITS.maxClickDepth)
|
|
1060
|
+
.sort((a, b) => depth.get(key(b.url)) - depth.get(key(a.url)));
|
|
1061
|
+
for (const p of deep.slice(0, 20)) {
|
|
1062
|
+
const clicks = depth.get(key(p.url));
|
|
1063
|
+
out.push(f('info', 'deep-page', `${clicks} clicks from the homepage`,
|
|
1064
|
+
`${routeTo(key(p.url))} — the shortest route in. Anything worth ranking is usually worth ` +
|
|
1065
|
+
`reaching in ${LIMITS.maxClickDepth}.`, p.url));
|
|
1066
|
+
}
|
|
1067
|
+
if (deep.length > 20) {
|
|
1068
|
+
out.push(f('info', 'deep-page-more', `${deep.length - 20} more pages are over ${LIMITS.maxClickDepth} clicks deep`,
|
|
1069
|
+
`${deep.length} of ${live.length} crawled pages sit deeper than ${LIMITS.maxClickDepth} clicks. ` +
|
|
1070
|
+
'The first 20 are listed above, deepest first.', home.url));
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// --- Anchor text --------------------------------------------------------
|
|
1076
|
+
// The words attached to a link are the one description of a page that comes
|
|
1077
|
+
// from outside it, and the only signal on this list that a page cannot write
|
|
1078
|
+
// about itself. Two things can go wrong with them, and only one is a matter
|
|
1079
|
+
// of taste.
|
|
1080
|
+
const inbound = new Map();
|
|
1081
|
+
const named = new Set();
|
|
1082
|
+
const blank = new Map();
|
|
1083
|
+
for (const p of live) {
|
|
1084
|
+
for (const { href, name } of p.doc.links.anchorTexts ?? []) {
|
|
1085
|
+
// Keyed without the trailing slash, because a page can link to itself
|
|
1086
|
+
// both ways and mean the same page. wordpress.org/education names Campus
|
|
1087
|
+
// Connect three times at `/campus-connect/` and once, wordlessly, at
|
|
1088
|
+
// `/campus-connect` — matching the strings would have called a page with
|
|
1089
|
+
// three good links unreadable.
|
|
1090
|
+
const target = withoutSlash(href);
|
|
1091
|
+
if (!name) {
|
|
1092
|
+
blank.set(target, [...(blank.get(target) ?? []), p.url]);
|
|
1093
|
+
continue; // no words at all is the finding below, not a vocabulary problem
|
|
1094
|
+
}
|
|
1095
|
+
named.add(target);
|
|
1096
|
+
inbound.set(target, [...(inbound.get(target) ?? []), name]);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
// A destination is only unreadable if *nothing* names it. The card is the
|
|
1100
|
+
// reason: a thumbnail with an emptied alt and the headline beside it are two
|
|
1101
|
+
// links to one article, and elementor.com's blog index has twenty-three of
|
|
1102
|
+
// them. The headline says what the article is, so Google is not in the dark
|
|
1103
|
+
// and neither is anybody else — reporting that would be the noisiest kind of
|
|
1104
|
+
// wrong, a true observation with a false conclusion attached.
|
|
1105
|
+
const nameless = new Map([...blank].filter(([href]) => !named.has(href)));
|
|
1106
|
+
|
|
1107
|
+
// A link with no text, no image alt, no aria-label and no title. Google is
|
|
1108
|
+
// told a page exists and nothing whatever about it; a screen reader reads the
|
|
1109
|
+
// URL aloud, one slash at a time. Usually an icon — a bare <i class="…">, or
|
|
1110
|
+
// a thumbnail whose alt was emptied because the headline beside it is a
|
|
1111
|
+
// second link to the same place.
|
|
1112
|
+
//
|
|
1113
|
+
// Grouped by destination rather than reported per page, because the ones that
|
|
1114
|
+
// exist are nearly always in a header or a footer, and the same social icon
|
|
1115
|
+
// on two hundred pages is one thing to fix.
|
|
1116
|
+
const namelessTargets = [...nameless].sort((a, b) => b[1].length - a[1].length);
|
|
1117
|
+
for (const [href, pages] of namelessTargets.slice(0, 10)) {
|
|
1118
|
+
out.push(f('warn', 'link-no-text', 'Link with nothing to read',
|
|
1119
|
+
`${href} is linked with no text, no image alt, no aria-label and no title, from ` +
|
|
1120
|
+
`${pages.length} page(s): ${pages.slice(0, 3).join(', ')}. Google is told the page exists and ` +
|
|
1121
|
+
'nothing about it, and a screen reader announces the URL instead of a description.',
|
|
1122
|
+
pages[0]));
|
|
1123
|
+
}
|
|
1124
|
+
if (namelessTargets.length > 10) {
|
|
1125
|
+
out.push(f('info', 'link-no-text-more', `${namelessTargets.length - 10} more destinations are linked with no text`,
|
|
1126
|
+
`${namelessTargets.length} in all. The ten linked from the most pages are listed above.`,
|
|
1127
|
+
namelessTargets[0][1][0]));
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// A page every one of whose inbound links says "read more". Each of those
|
|
1131
|
+
// links on its own is ordinary — a card under a headline has to say
|
|
1132
|
+
// something — and reporting them one at a time would fire on every blog
|
|
1133
|
+
// index ever built. What is worth knowing is the page that has nothing else:
|
|
1134
|
+
// no link anywhere on the site tells Google what it is about.
|
|
1135
|
+
const generic = live.filter((p) => {
|
|
1136
|
+
const names = inbound.get(withoutSlash(p.url));
|
|
1137
|
+
return names?.length && names.every((n) => GENERIC_ANCHORS.has(anchorPhrase(n)));
|
|
1138
|
+
});
|
|
1139
|
+
for (const p of generic.slice(0, 10)) {
|
|
1140
|
+
const names = inbound.get(withoutSlash(p.url));
|
|
1141
|
+
const shown = [...new Set(names.map((n) => `"${n}"`))].slice(0, 3).join(', ');
|
|
1142
|
+
out.push(f('info', 'anchor-generic', 'Every link to this page says the same empty thing',
|
|
1143
|
+
`${names.length} link(s) point here and all of them read ${shown}. Anchor text is the one ` +
|
|
1144
|
+
'description of a page that comes from somewhere other than the page itself, and this one has ' +
|
|
1145
|
+
'none — the words say what to do, not what is there.', p.url));
|
|
1146
|
+
}
|
|
1147
|
+
// The mirror of the check above: one phrase pointing at two different pages.
|
|
1148
|
+
// "Pricing" going to /pricing on some pages and /plans on others tells Google
|
|
1149
|
+
// the two are the same thing, so they compete instead of one of them winning.
|
|
1150
|
+
//
|
|
1151
|
+
// Destinations are compared by path, not by URL. A link to /collections/all
|
|
1152
|
+
// and one to /collections/all?sort_by=price are the same page described the
|
|
1153
|
+
// same way, which is not this finding and would bury it.
|
|
1154
|
+
//
|
|
1155
|
+
// Both destinations have to be pages this crawl actually fetched. Two of the
|
|
1156
|
+
// first real collisions found were not two pages at all: elementor.com's
|
|
1157
|
+
// /about/privacy/ 301s to /terms/privacy/, and smashingmagazine.com's
|
|
1158
|
+
// /categories/business 301s to /category/business. One page under two URLs
|
|
1159
|
+
// linked by the same words is a stale link — `link-redirects` reports it —
|
|
1160
|
+
// and calling it two competing pages would be false. A URL that answered 200
|
|
1161
|
+
// in this crawl is known to be a page; nothing else is.
|
|
1162
|
+
const crawled = new Set(live.map((p) => {
|
|
1163
|
+
try {
|
|
1164
|
+
const u = new URL(p.url);
|
|
1165
|
+
return withoutSlash(u.origin + u.pathname);
|
|
1166
|
+
} catch {
|
|
1167
|
+
return withoutSlash(p.url);
|
|
1168
|
+
}
|
|
1169
|
+
}));
|
|
1170
|
+
|
|
1171
|
+
const destinations = new Map();
|
|
1172
|
+
for (const p of live) {
|
|
1173
|
+
for (const { href, name } of p.doc.links.anchorTexts ?? []) {
|
|
1174
|
+
const phrase = anchorPhrase(name);
|
|
1175
|
+
// Controls rather than descriptions: a page number, an arrow, "next".
|
|
1176
|
+
// Their whole job is to be the same words in different places.
|
|
1177
|
+
// A version number or a page number is not a description — "7.1"
|
|
1178
|
+
// normalises to "7 1", which is why this is not just \d+.
|
|
1179
|
+
if (!phrase || phrase.length < 3 || /^[\d\s]+$/.test(phrase)) continue;
|
|
1180
|
+
if (GENERIC_ANCHORS.has(phrase)) continue;
|
|
1181
|
+
if (NAVIGATION_ANCHORS.has(phrase) || CONTROL_PHRASE.test(phrase)) continue;
|
|
1182
|
+
let path;
|
|
1183
|
+
try {
|
|
1184
|
+
const u = new URL(href);
|
|
1185
|
+
if (ASSET_FILE.test(u.pathname)) continue;
|
|
1186
|
+
path = withoutSlash(u.origin + u.pathname);
|
|
1187
|
+
} catch {
|
|
1188
|
+
continue;
|
|
1189
|
+
}
|
|
1190
|
+
if (!crawled.has(path)) continue;
|
|
1191
|
+
const seen = destinations.get(phrase) ?? new Map();
|
|
1192
|
+
if (!seen.has(path)) seen.set(path, { href, from: p.url });
|
|
1193
|
+
destinations.set(phrase, seen);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
const ambiguous = [...destinations].filter(
|
|
1197
|
+
([, targets]) => targets.size > 1 && targets.size <= AMBIGUOUS_CEILING,
|
|
1198
|
+
);
|
|
1199
|
+
for (const [phrase, targets] of ambiguous.slice(0, 10)) {
|
|
1200
|
+
const shown = [...targets.values()].slice(0, 3).map((t) => t.href);
|
|
1201
|
+
out.push(f('info', 'anchor-ambiguous', `"${phrase}" links to ${targets.size} different pages`,
|
|
1202
|
+
`${shown.join(', ')}${targets.size > 3 ? `, and ${targets.size - 3} more` : ''}. The words on a link ` +
|
|
1203
|
+
'are how Google is told what is on the other side, and these say the same thing about pages that ' +
|
|
1204
|
+
'are not the same — so the pages compete for it rather than one of them winning.',
|
|
1205
|
+
[...targets.values()][0].from));
|
|
1206
|
+
}
|
|
1207
|
+
if (ambiguous.length > 10) {
|
|
1208
|
+
out.push(f('info', 'anchor-ambiguous-more', `${ambiguous.length - 10} more phrases link to several pages each`,
|
|
1209
|
+
`${ambiguous.length} in all. The ten found first are listed above.`,
|
|
1210
|
+
[...ambiguous[0][1].values()][0].from));
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
if (generic.length > 10) {
|
|
1214
|
+
out.push(f('info', 'anchor-generic-more', `${generic.length - 10} more pages are linked only by generic anchors`,
|
|
1215
|
+
`${generic.length} of ${live.length} crawled pages have no inbound link that describes them.`,
|
|
1216
|
+
generic[0].url));
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// x-default names the version to serve someone whose language matches none of
|
|
1220
|
+
// the others. Reported once for the whole site rather than on every page,
|
|
1221
|
+
// because on a translated site the answer is the same on all of them.
|
|
1222
|
+
const translated = live.filter((p) => p.doc.hreflang.length);
|
|
1223
|
+
if (translated.length) {
|
|
1224
|
+
const hasDefault = translated.some((p) =>
|
|
1225
|
+
p.doc.hreflang.some((alt) => (alt.lang ?? '').toLowerCase() === 'x-default'),
|
|
1226
|
+
);
|
|
1227
|
+
if (!hasDefault) {
|
|
1228
|
+
out.push(f('info', 'hreflang-no-x-default', 'No x-default in the hreflang set',
|
|
1229
|
+
`${translated.length} pages declare alternates and none names an x-default — the version to serve ` +
|
|
1230
|
+
'a visitor whose language matches none of the others. Usually the English or the country selector.',
|
|
1231
|
+
translated[0].url));
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// hreflang has to point both ways, or Google ignores the pair.
|
|
1236
|
+
const byUrl = new Map(live.map((p) => [p.url.replace(/\/$/, ''), p]));
|
|
1237
|
+
for (const p of live) {
|
|
1238
|
+
for (const alt of p.doc.hreflang) {
|
|
1239
|
+
if (!alt.href || alt.lang === 'x-default') continue;
|
|
1240
|
+
const target = byUrl.get(alt.href.replace(/\/$/, ''));
|
|
1241
|
+
if (!target) continue; // outside the crawl — cannot judge
|
|
1242
|
+
const returns = target.doc.hreflang.some(
|
|
1243
|
+
(a) => a.href && a.href.replace(/\/$/, '') === p.url.replace(/\/$/, ''),
|
|
1244
|
+
);
|
|
1245
|
+
if (!returns) {
|
|
1246
|
+
out.push(f('error', 'hreflang-one-way', 'hreflang is not reciprocal',
|
|
1247
|
+
`${p.url} → ${alt.href} (${alt.lang}), but the target does not link back. Google drops one-way pairs.`, p.url));
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
return out;
|
|
1253
|
+
}
|