@rivi-software/agentic-seo-check 0.2.0 → 0.4.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.
Files changed (80) hide show
  1. package/README.md +9 -5
  2. package/dist/core/aeo/page.d.ts +0 -10
  3. package/dist/core/aeo/page.js +1 -29
  4. package/dist/core/aeo/page.js.map +1 -1
  5. package/dist/core/audit.js +3 -0
  6. package/dist/core/audit.js.map +1 -1
  7. package/dist/core/crawl.d.ts +2 -0
  8. package/dist/core/crawl.js +61 -4
  9. package/dist/core/crawl.js.map +1 -1
  10. package/dist/core/fetch.d.ts +9 -0
  11. package/dist/core/fetch.js +28 -12
  12. package/dist/core/fetch.js.map +1 -1
  13. package/dist/core/html/dom.d.ts +2 -0
  14. package/dist/core/html/dom.js +26 -1
  15. package/dist/core/html/dom.js.map +1 -1
  16. package/dist/core/html/extract.js +18 -3
  17. package/dist/core/html/extract.js.map +1 -1
  18. package/dist/core/html/head.d.ts +10 -0
  19. package/dist/core/html/head.js +206 -0
  20. package/dist/core/html/head.js.map +1 -0
  21. package/dist/core/html/resources.d.ts +17 -0
  22. package/dist/core/html/resources.js +172 -0
  23. package/dist/core/html/resources.js.map +1 -0
  24. package/dist/core/html/structured-data.d.ts +2 -0
  25. package/dist/core/html/structured-data.js +2 -0
  26. package/dist/core/html/structured-data.js.map +1 -1
  27. package/dist/core/robots.js +1 -0
  28. package/dist/core/robots.js.map +1 -1
  29. package/dist/core/rules/aeo.js +2 -2
  30. package/dist/core/rules/aeo.js.map +1 -1
  31. package/dist/core/rules/canonical.d.ts +3 -0
  32. package/dist/core/rules/canonical.js +54 -2
  33. package/dist/core/rules/canonical.js.map +1 -1
  34. package/dist/core/rules/document.js +4 -2
  35. package/dist/core/rules/document.js.map +1 -1
  36. package/dist/core/rules/head.d.ts +1 -0
  37. package/dist/core/rules/head.js +116 -0
  38. package/dist/core/rules/head.js.map +1 -0
  39. package/dist/core/rules/index.js +8 -0
  40. package/dist/core/rules/index.js.map +1 -1
  41. package/dist/core/rules/indexing.d.ts +10 -0
  42. package/dist/core/rules/indexing.js +171 -9
  43. package/dist/core/rules/indexing.js.map +1 -1
  44. package/dist/core/rules/keyword.js +1 -1
  45. package/dist/core/rules/keyword.js.map +1 -1
  46. package/dist/core/rules/loading.d.ts +1 -0
  47. package/dist/core/rules/loading.js +138 -0
  48. package/dist/core/rules/loading.js.map +1 -0
  49. package/dist/core/rules/meta.js +4 -1
  50. package/dist/core/rules/meta.js.map +1 -1
  51. package/dist/core/rules/rule.d.ts +2 -0
  52. package/dist/core/rules/rule.js +2 -0
  53. package/dist/core/rules/rule.js.map +1 -1
  54. package/dist/core/rules/structured-data-values.d.ts +3 -0
  55. package/dist/core/rules/structured-data-values.js +316 -0
  56. package/dist/core/rules/structured-data-values.js.map +1 -0
  57. package/dist/core/rules/structured-data.js +50 -20
  58. package/dist/core/rules/structured-data.js.map +1 -1
  59. package/dist/core/rules/urls.d.ts +1 -0
  60. package/dist/core/rules/urls.js +293 -0
  61. package/dist/core/rules/urls.js.map +1 -0
  62. package/dist/core/site-probes.d.ts +33 -0
  63. package/dist/core/site-probes.js +144 -0
  64. package/dist/core/site-probes.js.map +1 -0
  65. package/dist/core/site-rules.d.ts +18 -0
  66. package/dist/core/site-rules.js +487 -7
  67. package/dist/core/site-rules.js.map +1 -1
  68. package/dist/core/sitemap.d.ts +38 -1
  69. package/dist/core/sitemap.js +177 -43
  70. package/dist/core/sitemap.js.map +1 -1
  71. package/dist/core/static.d.ts +6 -0
  72. package/dist/core/static.js +121 -12
  73. package/dist/core/static.js.map +1 -1
  74. package/dist/core/types.d.ts +91 -0
  75. package/dist/core/types.js.map +1 -1
  76. package/dist/core/url.d.ts +2 -0
  77. package/dist/core/url.js +8 -0
  78. package/dist/core/url.js.map +1 -1
  79. package/package.json +1 -1
  80. package/skills/seo-audit/SKILL.md +14 -0
@@ -1,7 +1,9 @@
1
1
  import { normalizeForComparison, shingles, jaccard } from "./html/text.js";
2
+ import { agentMatcher } from "./robots.js";
2
3
  import { robotsDirectives } from "./rules/indexing.js";
3
4
  import { finding, GOOGLE_DOCS, cap } from "./rules/rule.js";
4
- import { normalizeUrl, normalizePath } from "./url.js";
5
+ import { MIN_COMPARABLE_WORDS, SAME_TEXT } from "./site-probes.js";
6
+ import { normalizeUrl, normalizePath, trimTrailingSlashes } from "./url.js";
5
7
  const meta = (id, severity, description, recommendation, docs) => ({
6
8
  id,
7
9
  category: "site",
@@ -16,8 +18,16 @@ export const SITE_RULES = [
16
18
  meta("site.robots-txt-error", "warning", 'robots.txt returned a server error or could not be fetched. Google treats 5xx robots.txt as "do not crawl anything".', "Make /robots.txt return 200 (or 404 if you have none).", GOOGLE_DOCS.robotsTxt),
17
19
  meta("site.robots-txt-blocks-all", "error", "robots.txt disallows crawling of the whole site for all user agents.", 'Remove "Disallow: /" for "User-agent: *" unless the site is intentionally private (staging).', GOOGLE_DOCS.robotsTxt),
18
20
  meta("site.robots-blocked-pages", "warning", "Pages linked internally (or listed in the sitemap) are disallowed by robots.txt.", "Allow the paths in robots.txt, or stop linking to them and remove them from the sitemap if they should stay hidden.", GOOGLE_DOCS.robotsTxt),
21
+ meta("site.robots-txt-too-large", "warning", "robots.txt is larger than 500 KiB; Google ignores everything after the first 500 KiB.", "Shorten robots.txt: group paths under shared prefixes, use wildcards (Disallow: /*?sort=) instead of listing URLs one by one, and remove rules for paths that no longer exist.", "https://developers.google.com/crawling/docs/robots-txt/robots-txt-spec"),
22
+ meta("site.robots-txt-unsupported-rule", "warning", "robots.txt uses rules Google does not support: noindex or nofollow lines (retired in 2019, so the pages stay indexable; warning) or crawl-delay (Google ignores it, Bing reads it; info).", "Remove noindex: and nofollow: lines and use a robots meta tag or X-Robots-Tag header on the pages instead. Crawl-delay may stay for Bing and other crawlers; Google sets its crawl rate itself.", "https://developers.google.com/crawling/docs/robots-txt/robots-txt-spec"),
23
+ meta("site.robots-blocks-resources", "warning", "robots.txt keeps Googlebot from scripts or stylesheets the pages load, so Google renders the pages without them.", "Allow the CSS and JavaScript files pages need (Allow: /assets/, or remove the Disallow line for that folder). Keep blocking only what pages do not need to render.", "https://developers.google.com/search/docs/crawling-indexing/robots/intro"),
19
24
  meta("site.sitemap-missing", "warning", "No XML sitemap was found (robots.txt Sitemap: line or /sitemap.xml).", "Generate a sitemap.xml listing canonical, indexable URLs and reference it from robots.txt.", GOOGLE_DOCS.sitemaps),
20
25
  meta("site.sitemap-error", "error", "A sitemap could not be fetched or parsed.", "Make the sitemap return 200 with valid XML (urlset or sitemapindex).", GOOGLE_DOCS.sitemaps),
26
+ meta("site.sitemap-too-large", "error", "A sitemap holds more than 50,000 URLs or more than 50 MB uncompressed, which search engines do not read (error); or it is larger than the 15 MB this audit reads, so it was not checked (info).", "Split the sitemap into files of at most 50,000 URLs and 50 MB each, and list them in a sitemap index.", "https://developers.google.com/search/docs/crawling-indexing/sitemaps/large-sitemaps"),
27
+ meta("site.sitemap-index-nested", "warning", "A sitemap index lists another sitemap index; Google reads only sitemaps from an index, not further indexes.", "List every sitemap file directly in one sitemap index (up to 50,000), or submit each index separately in Search Console.", "https://developers.google.com/search/docs/crawling-indexing/sitemaps/large-sitemaps"),
28
+ meta("site.sitemap-lastmod-invalid", "warning", "Sitemap <lastmod> values are not in W3C Datetime format, so search engines ignore them.", "Write <lastmod> as a date (2026-11-09) or a date and time with its time zone (2026-11-09T10:00:00+01:00).", "https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap"),
29
+ meta("site.sitemap-lastmod-future", "info", "Sitemap <lastmod> values lie in the future. Google uses lastmod only when it is consistently accurate.", "Set <lastmod> to when the page's content last changed, not to the build time plus an offset or a publish date to come.", "https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap"),
30
+ meta("site.sitemap-other-host", "info", "The site's sitemaps list URLs on other hosts. Google accepts them only for hosts verified in Search Console or whose own robots.txt names the sitemap.", "List each host's URLs in a sitemap that host's robots.txt names. If the URLs use another domain by mistake (a staging or old domain), generate them from the production site URL.", "https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap"),
21
31
  meta("site.sitemap-noindex-page", "error", "The sitemap lists pages that are set to noindex.", "Either remove noindex (if the page should rank) or remove the URL from the sitemap. Conflicting signals waste crawl budget.", GOOGLE_DOCS.sitemaps),
22
32
  meta("site.sitemap-non-canonical", "warning", "The sitemap lists URLs whose canonical points to a different URL.", "List only canonical URLs in the sitemap.", GOOGLE_DOCS.sitemaps),
23
33
  meta("site.sitemap-broken-url", "error", "The sitemap lists URLs that return 4xx/5xx.", "Remove dead URLs from the sitemap or fix them.", GOOGLE_DOCS.sitemaps),
@@ -38,6 +48,15 @@ export const SITE_RULES = [
38
48
  meta("site.hreflang-no-return-link", "warning", "hreflang alternates do not link back to the page that references them.", "Every page in an hreflang cluster must list all alternates including itself; mismatches invalidate the annotations.", GOOGLE_DOCS.hreflang),
39
49
  meta("site.broken-external-links", "warning", "External links point to URLs that return 4xx/5xx or fail to load.", "Fix or remove the dead outbound links; they hurt user trust and signal an unmaintained page.", GOOGLE_DOCS.links),
40
50
  meta("site.unlinked-pages", "info", "HTML files in the build output that no other page links to.", "Link the pages from navigation or related content, or remove them from the build if they are obsolete.", GOOGLE_DOCS.links),
51
+ meta("site.canonical-target-broken", "error", "Pages name a canonical URL that answers 4xx/5xx or does not load, so search engines cannot use it.", "Point the canonical at the live URL of the preferred page, usually the page itself. Canonicals built from a route pattern often miss a trailing slash or a locale prefix.", GOOGLE_DOCS.canonical),
52
+ meta("site.canonical-target-redirects", "warning", "Pages name a canonical URL that redirects; the canonical should be the final URL.", "Write the URL the redirect ends at in the canonical: the same scheme, host, trailing slash and case the server answers with.", GOOGLE_DOCS.canonical),
53
+ meta("site.canonical-target-noindex", "warning", "Pages name as canonical a page that is set to noindex, so the page meant to be indexed is kept out of the index.", "Remove noindex from the canonical page, or point the canonical somewhere indexable. If both pages should stay out of search results, use noindex on both and drop the canonical.", GOOGLE_DOCS.canonical),
54
+ meta("site.canonical-chain", "warning", "A page's canonical points to a page whose own canonical points somewhere else (A to B to C).", "Point every page's canonical straight at the final preferred URL.", GOOGLE_DOCS.canonical),
55
+ meta("site.links-to-noindex", "info", "Pages set to noindex are linked from other pages of the site. A noindex page that much of the site links to is often noindex by mistake. Cart, search, login and account pages are left out.", "Check that each listed page should stay out of search results. If the noindex is deliberate (login, cart, internal search), leave it; otherwise remove the noindex.", GOOGLE_DOCS.robotsMeta),
56
+ meta("site.hreflang-target-invalid", "warning", "hreflang alternates point to URLs that are broken, redirect, are noindex or name another URL as canonical, so the annotation is ignored.", "List only the canonical, indexable URL of each language version in hreflang: the final URL, not one that redirects.", GOOGLE_DOCS.hreflang),
57
+ meta("site.url-variant-duplicate", "warning", "A page also answers 200 at its URL with the trailing slash added or removed, or with the path in another case, without redirecting or naming the page as canonical. Google treats each spelling as a separate URL.", "Redirect (301) the other spelling to the page's URL in the server, framework or CDN (trailingSlash in Next.js, Nuxt and Astro configs; a lowercase rewrite on case-insensitive servers), or at least give every spelling a canonical to the one URL.", GOOGLE_DOCS.urlStructure),
58
+ meta("site.parameter-duplicates", "warning", "Crawled URLs that differ only in their query string show the same text, and none names one of them as canonical.", "Give the parameter versions a canonical to the URL without the parameter (or the one you want indexed), and link to that URL. Parameters that change nothing visible (session, sort order of one item, view=) should not reach links at all.", GOOGLE_DOCS.canonical),
59
+ meta("site.broken-images", "warning", "Images on the pages point to URLs that answer 4xx/5xx or do not load.", "Fix the image URLs listed in evidence, or upload the missing files. Each entry names the pages that show the image.", GOOGLE_DOCS.images),
41
60
  meta("site.crawl-limit-reached", "info", "The crawl stopped at the page limit, so site-wide checks (orphans, duplicates, sitemap coverage) are partial.", "Re-run with a higher maxPages value for a complete picture."),
42
61
  ];
43
62
  const byId = new Map(SITE_RULES.map((r) => [r.id, r]));
@@ -68,6 +87,9 @@ const pathKey = (url) => {
68
87
  };
69
88
  const pushDuplicates = (out, rule, pages, pick, label) => {
70
89
  const groups = new Map();
90
+ // Pages are grouped by a lowercased key, but the evidence quotes the value as the first page
91
+ // wrote it, so a report or pull request shows the text a reviewer would find on the site.
92
+ const written = new Map();
71
93
  for (const p of pages) {
72
94
  const value = pick(p);
73
95
  if (!value) {
@@ -75,6 +97,9 @@ const pushDuplicates = (out, rule, pages, pick, label) => {
75
97
  }
76
98
  const k = normalizeForComparison(value);
77
99
  pushTo(groups, k, p.url);
100
+ if (!written.has(k)) {
101
+ written.set(k, value);
102
+ }
78
103
  }
79
104
  const dupes = [...groups.entries()].filter(([, urls]) => urls.length > 1);
80
105
  if (!dupes.length) {
@@ -83,8 +108,8 @@ const pushDuplicates = (out, rule, pages, pick, label) => {
83
108
  const pagesAffected = dupes.reduce((n, [, urls]) => n + urls.length, 0);
84
109
  out.push(finding(rule, `${dupes.length} ${label}(s) are shared by ${pagesAffected} pages.`, {
85
110
  evidence: {
86
- groups: cap(dupes.map(([value, urls]) => ({
87
- value: value.slice(0, 120),
111
+ groups: cap(dupes.map(([k, urls]) => ({
112
+ value: (written.get(k) ?? k).slice(0, 120),
88
113
  pages: cap(urls, 5),
89
114
  count: urls.length,
90
115
  }))),
@@ -134,6 +159,148 @@ const pushDuplicateContent = (out, pages) => {
134
159
  out.push(finding(m("site.duplicate-content"), `${groups.length} group(s) of pages have (near-)identical body text.`, { evidence: { groups: cap(groups) } }));
135
160
  }
136
161
  };
162
+ /** Google reads the first 500 KiB of robots.txt and ignores the rest. */
163
+ const ROBOTS_MAX_BYTES = 512_000;
164
+ const unsupportedRobotsLines = (text) => {
165
+ const retired = [];
166
+ const crawlDelay = [];
167
+ for (const [i, raw] of text.split(/\r?\n/u).slice(0, 50_000).entries()) {
168
+ const line = raw.replace(/#.*$/u, "").trim();
169
+ const field = /^(?<field>[a-z-]+)\s*:/iu
170
+ .exec(line)
171
+ ?.groups?.field?.toLowerCase();
172
+ if (field === "noindex" || field === "nofollow") {
173
+ retired.push(`line ${i + 1}: ${line.slice(0, 120)}`);
174
+ }
175
+ else if (field === "crawl-delay") {
176
+ crawlDelay.push(`line ${i + 1}: ${line.slice(0, 120)}`);
177
+ }
178
+ }
179
+ return { retired, crawlDelay };
180
+ };
181
+ /** robots.txt's size and the rules in it Google does not support. */
182
+ const robotsContentFindings = (ctx) => {
183
+ const out = [];
184
+ const bytes = ctx.robots?.bytes ?? 0;
185
+ if (bytes > ROBOTS_MAX_BYTES) {
186
+ out.push(finding(m("site.robots-txt-too-large"), `robots.txt is ${Math.round(bytes / 1024)} KiB; Google reads only the first 500 KiB.`, { evidence: { url: ctx.robots?.url, bytes } }));
187
+ }
188
+ const text = ctx.robotsText ?? ctx.robots?.text;
189
+ if (!text) {
190
+ return out;
191
+ }
192
+ const { retired, crawlDelay } = unsupportedRobotsLines(text);
193
+ if (retired.length > 0 || crawlDelay.length > 0) {
194
+ out.push(finding(m("site.robots-txt-unsupported-rule"), retired.length > 0
195
+ ? `robots.txt has ${retired.length} noindex or nofollow line(s), which Google stopped reading in 2019.`
196
+ : `robots.txt sets crawl-delay, which Google ignores.`, {
197
+ severity: retired.length > 0 ? "warning" : "info",
198
+ evidence: {
199
+ url: ctx.robots?.url,
200
+ retired: cap(retired),
201
+ crawlDelay: cap(crawlDelay),
202
+ },
203
+ }));
204
+ }
205
+ return out;
206
+ };
207
+ const originOf = (url) => {
208
+ try {
209
+ return new URL(url).origin;
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ };
215
+ /** Scripts and stylesheets on the site's own origin that robots.txt keeps Googlebot from. */
216
+ const blockedResourceFindings = ({ ctx, okPages, isIndexable, }) => {
217
+ const { robots, robotsText: text } = ctx;
218
+ if (!robots?.found || !text) {
219
+ return [];
220
+ }
221
+ const origin = originOf(robots.url);
222
+ if (!origin) {
223
+ return [];
224
+ }
225
+ const allowed = agentMatcher(robots.url, text);
226
+ const verdicts = new Map();
227
+ const blocked = new Map();
228
+ // Only pages meant for search: Google need not render a noindex checkout or account page.
229
+ for (const p of okPages.filter(isIndexable)) {
230
+ for (const r of p.page.resources) {
231
+ if (r.kind !== "script" && r.kind !== "stylesheet") {
232
+ continue;
233
+ }
234
+ // This robots.txt has no say over files on a CDN or another host.
235
+ if (originOf(r.url) !== origin) {
236
+ continue;
237
+ }
238
+ let ok = verdicts.get(r.url);
239
+ if (ok === undefined) {
240
+ ok = allowed(r.url, "Googlebot");
241
+ verdicts.set(r.url, ok);
242
+ }
243
+ if (!ok) {
244
+ const pages = blocked.get(r.url) ?? new Set();
245
+ pages.add(p.url);
246
+ blocked.set(r.url, pages);
247
+ }
248
+ }
249
+ }
250
+ const list = [...blocked]
251
+ .map(([url, pages]) => ({ url, pages: pages.size }))
252
+ .toSorted((a, b) => b.pages - a.pages);
253
+ return list.length > 0
254
+ ? [
255
+ finding(m("site.robots-blocks-resources"), `robots.txt keeps Googlebot from ${list.length} script(s) or stylesheet(s) the pages load.`, { evidence: { count: list.length, examples: cap(list) } }),
256
+ ]
257
+ : [];
258
+ };
259
+ /** Sitemaps over the protocol's limits, nested indexes, bad lastmod values and other hosts. */
260
+ const sitemapProtocolFindings = (ctx) => {
261
+ const { problems } = ctx.sitemaps;
262
+ const out = [];
263
+ const over = problems.oversized.filter((o) => o.overLimit);
264
+ const unread = problems.oversized.filter((o) => !o.overLimit);
265
+ if (over.length > 0) {
266
+ out.push(finding(m("site.sitemap-too-large"), `${over.length} sitemap(s) exceed 50,000 URLs or 50 MB.`, { evidence: { sitemaps: cap(over) } }));
267
+ }
268
+ if (unread.length > 0) {
269
+ out.push(finding(m("site.sitemap-too-large"), `${unread.length} sitemap(s) are larger than the 15 MB this audit reads, so their URLs were not checked.`, {
270
+ severity: "info",
271
+ recommendation: "Check that each stays under 50 MB and 50,000 URLs; smaller sitemaps (10,000 URLs or fewer) are also quicker for search engines to fetch again.",
272
+ evidence: { sitemaps: cap(unread) },
273
+ }));
274
+ }
275
+ if (problems.nestedIndexes.length > 0) {
276
+ out.push(finding(m("site.sitemap-index-nested"), `${problems.nestedIndexes.length} sitemap index(es) are listed by another sitemap index.`, { evidence: { indexes: cap(problems.nestedIndexes) } }));
277
+ }
278
+ const examples = [
279
+ [
280
+ "site.sitemap-lastmod-invalid",
281
+ problems.invalidLastmod,
282
+ "have a <lastmod> that is not a W3C Datetime",
283
+ ],
284
+ [
285
+ "site.sitemap-lastmod-future",
286
+ problems.futureLastmod,
287
+ "have a <lastmod> in the future",
288
+ ],
289
+ [
290
+ "site.sitemap-other-host",
291
+ problems.otherHost,
292
+ "are on another host than the site",
293
+ ],
294
+ ];
295
+ for (const [id, list, label] of examples) {
296
+ if (list.count > 0) {
297
+ out.push(finding(m(id), `${list.count} sitemap URL(s) ${label}.`, {
298
+ evidence: { count: list.count, examples: list.examples },
299
+ }));
300
+ }
301
+ }
302
+ return out;
303
+ };
137
304
  const robotsFindings = (ctx) => {
138
305
  const out = [];
139
306
  /* robots.txt */
@@ -259,7 +426,10 @@ const sitemapFindings = (scope) => {
259
426
  if (ctx.sitemaps.errors.length) {
260
427
  out.push(finding(m("site.sitemap-error"), `${ctx.sitemaps.errors.length} sitemap problem(s).`, { evidence: { errors: cap(ctx.sitemaps.errors) } }));
261
428
  }
262
- if (ctx.sitemaps.sources.length === 0 && ctx.sitemaps.errors.length === 0) {
429
+ // A sitemap too large to read was still found.
430
+ if (ctx.sitemaps.sources.length === 0 &&
431
+ ctx.sitemaps.errors.length === 0 &&
432
+ ctx.sitemaps.problems.oversized.length === 0) {
263
433
  out.push(finding(m("site.sitemap-missing"), "No sitemap was found.", {
264
434
  severity: ctx.source === "directory" ? "info" : "warning",
265
435
  }));
@@ -349,10 +519,11 @@ const unreachedFindings = (ctx) => {
349
519
  }
350
520
  return out;
351
521
  };
352
- const duplicateFindings = ({ okPages, isIndexable }) => {
522
+ const duplicateFindings = ({ key, okPages, isIndexable, }) => {
353
523
  const out = [];
354
- /* duplicates */
355
- const indexable = okPages.filter(isIndexable);
524
+ // Pages that name another URL as canonical are consolidated already; only the canonical ones compete.
525
+ const indexable = okPages.filter((p) => isIndexable(p) &&
526
+ (!p.page.canonical || key(p.page.canonical) === key(p.url)));
356
527
  pushDuplicates(out, m("site.duplicate-titles"), indexable, (p) => p.page.title, "title");
357
528
  pushDuplicates(out, m("site.duplicate-descriptions"), indexable, (p) => p.page.metaDescription, "meta description");
358
529
  pushDuplicates(out, m("site.duplicate-h1"), indexable, (p) => p.page.h1s[0] ?? null, "H1");
@@ -428,6 +599,306 @@ const hreflangFindings = ({ ctx, key, pagesByKey }) => {
428
599
  }
429
600
  return out;
430
601
  };
602
+ /** What is wrong with a URL that metadata (a canonical, an hreflang link) points to, or null. */
603
+ const targetProblem = ({ key, pagesByKey, checksByKey, isIndexable }, url) => {
604
+ const check = checksByKey.get(key(url));
605
+ if (check && (check.error || (check.status ?? 0) >= 400)) {
606
+ return {
607
+ kind: "broken",
608
+ detail: check.error ?? `HTTP ${check.status}`,
609
+ };
610
+ }
611
+ if (check && check.redirectChain.length > 0) {
612
+ return { kind: "redirects", detail: `redirects to ${check.finalUrl}` };
613
+ }
614
+ const target = pagesByKey.get(key(url));
615
+ if (!target) {
616
+ return null;
617
+ }
618
+ if (!isIndexable(target)) {
619
+ return { kind: "noindex", detail: "is noindex" };
620
+ }
621
+ const { canonical } = target.page;
622
+ return canonical && key(canonical) !== key(target.url)
623
+ ? { kind: "canonical", detail: `names ${canonical} as canonical` }
624
+ : null;
625
+ };
626
+ /** Canonicals that point at broken, redirecting, noindex or re-canonicalized URLs. */
627
+ const canonicalGraphFindings = (scope) => {
628
+ const { key, okPages, isIndexable } = scope;
629
+ const groups = {
630
+ broken: [],
631
+ redirects: [],
632
+ noindex: [],
633
+ canonical: [],
634
+ };
635
+ for (const p of okPages) {
636
+ const { canonical } = p.page;
637
+ // A noindex page's canonical carries no weight; the noindex wins.
638
+ if (!canonical || key(canonical) === key(p.url) || !isIndexable(p)) {
639
+ continue;
640
+ }
641
+ const problem = targetProblem(scope, canonical);
642
+ if (problem) {
643
+ groups[problem.kind].push({
644
+ page: p.url,
645
+ canonical,
646
+ problem: problem.detail,
647
+ });
648
+ }
649
+ }
650
+ const rules = [
651
+ [
652
+ "broken",
653
+ "site.canonical-target-broken",
654
+ "name a canonical URL that is broken",
655
+ ],
656
+ [
657
+ "redirects",
658
+ "site.canonical-target-redirects",
659
+ "name a canonical URL that redirects",
660
+ ],
661
+ [
662
+ "noindex",
663
+ "site.canonical-target-noindex",
664
+ "name a noindex page as canonical",
665
+ ],
666
+ [
667
+ "canonical",
668
+ "site.canonical-chain",
669
+ "name a canonical whose own canonical points elsewhere",
670
+ ],
671
+ ];
672
+ return rules.flatMap(([kind, id, label]) => {
673
+ const list = groups[kind];
674
+ return list.length > 0
675
+ ? [
676
+ finding(m(id), `${list.length} page(s) ${label}.`, {
677
+ evidence: { count: list.length, examples: cap(list) },
678
+ }),
679
+ ]
680
+ : [];
681
+ });
682
+ };
683
+ /** Pages a site keeps out of search results on purpose: cart, checkout, search, login, account. */
684
+ const UTILITY_PATH = /(?:^|\/)(?:cart|bag|basket|checkout|winkelwagen|search|zoeken|suche|login|log-in|signin|sign-in|logout|inloggen|account|my-account|register|signup|sign-up|wishlist|favorites|favourites)(?:[/.?]|$)/iu;
685
+ const isUtilityPage = (url) => {
686
+ try {
687
+ return UTILITY_PATH.test(new URL(url).pathname);
688
+ }
689
+ catch {
690
+ return false;
691
+ }
692
+ };
693
+ /** Noindex pages that other pages of the site link to, most linked first. */
694
+ const noindexLinkFindings = ({ ctx, key, okPages, isIndexable, }) => {
695
+ const linkedFrom = new Map();
696
+ for (const [target, sources] of ctx.linkSources) {
697
+ const k = key(target);
698
+ const set = linkedFrom.get(k) ?? new Set();
699
+ for (const source of sources) {
700
+ if (key(source) !== k) {
701
+ set.add(source);
702
+ }
703
+ }
704
+ linkedFrom.set(k, set);
705
+ }
706
+ const linked = okPages
707
+ .filter((p) => !isIndexable(p) && !isUtilityPage(p.url))
708
+ .map((p) => ({
709
+ url: p.url,
710
+ linkedFrom: linkedFrom.get(key(p.url))?.size ?? 0,
711
+ }))
712
+ .filter((p) => p.linkedFrom > 0)
713
+ .toSorted((a, b) => b.linkedFrom - a.linkedFrom);
714
+ return linked.length > 0
715
+ ? [
716
+ finding(m("site.links-to-noindex"), `${linked.length} noindex page(s) are linked from other pages; the most linked: ${linked[0]?.url} (${linked[0]?.linkedFrom} pages).`, { evidence: { count: linked.length, pages: cap(linked) } }),
717
+ ]
718
+ : [];
719
+ };
720
+ /** hreflang alternates that are broken, redirect, are noindex or canonicalize elsewhere. */
721
+ const hreflangTargetFindings = (scope) => {
722
+ const { key, okPages } = scope;
723
+ const invalid = [];
724
+ const seen = new Set();
725
+ for (const p of okPages) {
726
+ for (const alt of p.page.hreflang) {
727
+ if (!alt.url || key(alt.url) === key(p.url)) {
728
+ continue;
729
+ }
730
+ const problem = targetProblem(scope, alt.url);
731
+ const id = `${key(p.url)} ${key(alt.url)}`;
732
+ // Google documents x-default for a page that redirects each visitor to their language.
733
+ const languageRedirect = alt.lang.toLowerCase() === "x-default" && problem?.kind === "redirects";
734
+ if (problem && !languageRedirect && !seen.has(id)) {
735
+ seen.add(id);
736
+ invalid.push({
737
+ page: p.url,
738
+ hreflang: alt.lang,
739
+ alternate: alt.url,
740
+ problem: problem.detail,
741
+ });
742
+ }
743
+ }
744
+ }
745
+ return invalid.length > 0
746
+ ? [
747
+ finding(m("site.hreflang-target-invalid"), `${invalid.length} hreflang alternate(s) point to URLs that are broken, redirect, are noindex or are not canonical.`, { evidence: { count: invalid.length, examples: cap(invalid) } }),
748
+ ]
749
+ : [];
750
+ };
751
+ /** The URL with its path lower-cased and trailing slashes dropped: one key for every spelling. */
752
+ const spellingKey = (url) => {
753
+ try {
754
+ const u = new URL(url);
755
+ const pathname = trimTrailingSlashes(u.pathname.toLowerCase()) || "/";
756
+ return `${u.origin}${pathname}${u.search}`;
757
+ }
758
+ catch {
759
+ return url;
760
+ }
761
+ };
762
+ const pathnameOf = (url) => {
763
+ try {
764
+ return new URL(url).pathname;
765
+ }
766
+ catch {
767
+ return url;
768
+ }
769
+ };
770
+ /** How two spellings of one URL differ: only in the path's trailing slash, or in its letter case. */
771
+ const spellingDifference = (a, b) => trimTrailingSlashes(pathnameOf(a)) === trimTrailingSlashes(pathnameOf(b))
772
+ ? "trailing-slash"
773
+ : "case";
774
+ /** Pages the crawl reached under two spellings of one URL, both answering as a canonical page. */
775
+ const crawledVariants = ({ ctx, key, okPages, isIndexable, }) => {
776
+ if (ctx.pathOnly) {
777
+ return [];
778
+ }
779
+ const groups = new Map();
780
+ for (const p of okPages) {
781
+ if (isIndexable(p) &&
782
+ p.page.wordCount >= MIN_COMPARABLE_WORDS &&
783
+ (!p.page.canonical || key(p.page.canonical) === key(p.url))) {
784
+ const k = spellingKey(p.url);
785
+ groups.set(k, [...(groups.get(k) ?? []), p]);
786
+ }
787
+ }
788
+ return [...groups.values()].flatMap(([first, ...rest]) => first
789
+ ? rest
790
+ .filter((other) => jaccard(shingles(first.page.text), shingles(other.page.text)) >=
791
+ SAME_TEXT)
792
+ .map((other) => ({
793
+ kind: spellingDifference(first.url, other.url),
794
+ url: first.url,
795
+ variant: other.url,
796
+ status: other.response?.status ?? null,
797
+ canonical: other.page.canonical,
798
+ duplicate: true,
799
+ }))
800
+ : []);
801
+ };
802
+ /** Slash and case variants of crawled pages that the server answers as pages of their own. */
803
+ const urlVariantFindings = (scope) => {
804
+ const probed = (scope.ctx.probes.urlVariants ?? []).filter((v) => v.duplicate);
805
+ const seen = new Set(probed.map((v) => spellingKey(v.variant)));
806
+ const duplicates = [
807
+ ...probed,
808
+ ...crawledVariants(scope).filter((v) => !seen.has(spellingKey(v.variant))),
809
+ ];
810
+ if (duplicates.length === 0) {
811
+ return [];
812
+ }
813
+ const kinds = [...new Set(duplicates.map((d) => d.kind))];
814
+ return [
815
+ finding(m("site.url-variant-duplicate"), `${duplicates.length} page(s) also answer at a ${kinds.join(" and a ")} variant of their URL.`, {
816
+ evidence: {
817
+ count: duplicates.length,
818
+ examples: cap(duplicates.map(({ kind, url, variant, canonical }) => ({
819
+ kind,
820
+ url,
821
+ variant,
822
+ canonical,
823
+ }))),
824
+ },
825
+ }),
826
+ ];
827
+ };
828
+ const withoutQuery = (url) => {
829
+ try {
830
+ const u = new URL(url);
831
+ return `${u.origin}${u.pathname}`;
832
+ }
833
+ catch {
834
+ return url;
835
+ }
836
+ };
837
+ /** Pages that differ only in their query string, show the same text and do not consolidate. */
838
+ const parameterDuplicateFindings = ({ key, okPages, isIndexable, }) => {
839
+ const byPath = new Map();
840
+ for (const p of okPages) {
841
+ const { canonical } = p.page;
842
+ // A page whose canonical points elsewhere has already said which URL to index, and a page
843
+ // with almost no text in its HTML (rendered by JavaScript) gives nothing to compare.
844
+ if (!isIndexable(p) ||
845
+ (canonical && key(canonical) !== key(p.url)) ||
846
+ p.page.wordCount < MIN_COMPARABLE_WORDS) {
847
+ continue;
848
+ }
849
+ const path = withoutQuery(p.url);
850
+ byPath.set(path, [...(byPath.get(path) ?? []), p]);
851
+ }
852
+ const groups = [];
853
+ for (const [path, pages] of byPath) {
854
+ if (pages.length < 2 || !pages.some((p) => p.url.includes("?"))) {
855
+ continue;
856
+ }
857
+ const [first, ...rest] = pages;
858
+ if (!first) {
859
+ continue;
860
+ }
861
+ const base = shingles(first.page.text);
862
+ const same = rest.flatMap((p) => {
863
+ const similarity = jaccard(base, shingles(p.page.text));
864
+ return similarity >= SAME_TEXT ? [{ url: p.url, similarity }] : [];
865
+ });
866
+ if (same.length > 0) {
867
+ groups.push({
868
+ path,
869
+ urls: [first.url, ...same.map((s) => s.url)].toSorted(),
870
+ similarity: Number(Math.min(...same.map((s) => s.similarity)).toFixed(2)),
871
+ });
872
+ }
873
+ }
874
+ return groups.length > 0
875
+ ? [
876
+ finding(m("site.parameter-duplicates"), `${groups.length} path(s) are crawled under several query strings with the same text.`, { evidence: { count: groups.length, groups: cap(groups) } }),
877
+ ]
878
+ : [];
879
+ };
880
+ /** Images that answer 4xx/5xx or do not load, with the pages that show them. */
881
+ const brokenImageFindings = (ctx) => {
882
+ const broken = [];
883
+ for (const [url, check] of ctx.imageChecks ?? []) {
884
+ if (check.error || (check.status ?? 0) >= 400) {
885
+ const entry = {
886
+ url,
887
+ status: check.status,
888
+ shownOn: cap([...(ctx.imageSources?.get(url) ?? [])], 3),
889
+ };
890
+ if (check.error) {
891
+ entry.error = check.error;
892
+ }
893
+ broken.push(entry);
894
+ }
895
+ }
896
+ return broken.length > 0
897
+ ? [
898
+ finding(m("site.broken-images"), `${broken.length} image URL(s) are broken.`, { evidence: { count: broken.length, examples: cap(broken, 20) } }),
899
+ ]
900
+ : [];
901
+ };
431
902
  export const runSiteRules = (ctx) => {
432
903
  const key = (url) => ctx.pathOnly ? pathKey(url) : (normalizeUrl(url) ?? url);
433
904
  const pagesByKey = new Map();
@@ -451,11 +922,20 @@ export const runSiteRules = (ctx) => {
451
922
  };
452
923
  return [
453
924
  ...robotsFindings(ctx),
925
+ ...robotsContentFindings(ctx),
926
+ ...blockedResourceFindings(scope),
454
927
  ...sitemapFindings(scope),
928
+ ...sitemapProtocolFindings(ctx),
455
929
  ...linkHealthFindings(ctx),
456
930
  ...unreachedFindings(ctx),
457
931
  ...duplicateFindings(scope),
458
932
  ...architectureFindings(scope),
933
+ ...urlVariantFindings(scope),
934
+ ...parameterDuplicateFindings(scope),
935
+ ...brokenImageFindings(ctx),
936
+ ...canonicalGraphFindings(scope),
937
+ ...noindexLinkFindings(scope),
938
+ ...hreflangTargetFindings(scope),
459
939
  ...hreflangFindings(scope),
460
940
  ];
461
941
  };