@rivi-software/agentic-seo-check 0.1.0 → 0.2.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 (144) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +124 -18
  3. package/dist/brand.d.ts +11 -0
  4. package/dist/brand.js +22 -0
  5. package/dist/brand.js.map +1 -0
  6. package/dist/cli.js +174 -86
  7. package/dist/cli.js.map +1 -1
  8. package/dist/core/aeo/audit.d.ts +44 -0
  9. package/dist/core/aeo/audit.js +310 -0
  10. package/dist/core/aeo/audit.js.map +1 -0
  11. package/dist/core/aeo/bots.d.ts +30 -0
  12. package/dist/core/aeo/bots.js +299 -0
  13. package/dist/core/aeo/bots.js.map +1 -0
  14. package/dist/core/aeo/llms-txt.d.ts +8 -0
  15. package/dist/core/aeo/llms-txt.js +88 -0
  16. package/dist/core/aeo/llms-txt.js.map +1 -0
  17. package/dist/core/aeo/page.d.ts +53 -0
  18. package/dist/core/aeo/page.js +312 -0
  19. package/dist/core/aeo/page.js.map +1 -0
  20. package/dist/core/aeo/probe.d.ts +10 -0
  21. package/dist/core/aeo/probe.js +53 -0
  22. package/dist/core/aeo/probe.js.map +1 -0
  23. package/dist/core/aeo/site-rules.d.ts +13 -0
  24. package/dist/core/aeo/site-rules.js +178 -0
  25. package/dist/core/aeo/site-rules.js.map +1 -0
  26. package/dist/core/audit.d.ts +37 -16
  27. package/dist/core/audit.js +389 -249
  28. package/dist/core/audit.js.map +1 -1
  29. package/dist/core/concurrency.d.ts +4 -4
  30. package/dist/core/concurrency.js +33 -23
  31. package/dist/core/concurrency.js.map +1 -1
  32. package/dist/core/crawl.d.ts +9 -5
  33. package/dist/core/crawl.js +271 -123
  34. package/dist/core/crawl.js.map +1 -1
  35. package/dist/core/fetch.d.ts +5 -5
  36. package/dist/core/fetch.js +151 -100
  37. package/dist/core/fetch.js.map +1 -1
  38. package/dist/core/html/answer.d.ts +4 -0
  39. package/dist/core/html/answer.js +198 -0
  40. package/dist/core/html/answer.js.map +1 -0
  41. package/dist/core/html/dom.d.ts +17 -0
  42. package/dist/core/html/dom.js +83 -0
  43. package/dist/core/html/dom.js.map +1 -0
  44. package/dist/core/html/extract.d.ts +2 -2
  45. package/dist/core/html/extract.js +102 -111
  46. package/dist/core/html/extract.js.map +1 -1
  47. package/dist/core/html/structured-data.d.ts +16 -6
  48. package/dist/core/html/structured-data.js +180 -145
  49. package/dist/core/html/structured-data.js.map +1 -1
  50. package/dist/core/html/text.d.ts +14 -14
  51. package/dist/core/html/text.js +110 -107
  52. package/dist/core/html/text.js.map +1 -1
  53. package/dist/core/lighthouse.d.ts +26 -25
  54. package/dist/core/lighthouse.js +181 -105
  55. package/dist/core/lighthouse.js.map +1 -1
  56. package/dist/core/performance-rules.d.ts +2 -2
  57. package/dist/core/performance-rules.js +105 -33
  58. package/dist/core/performance-rules.js.map +1 -1
  59. package/dist/core/render-rules.d.ts +5 -4
  60. package/dist/core/render-rules.js +129 -62
  61. package/dist/core/render-rules.js.map +1 -1
  62. package/dist/core/render.d.ts +2 -2
  63. package/dist/core/render.js +29 -23
  64. package/dist/core/render.js.map +1 -1
  65. package/dist/core/report/markdown.d.ts +4 -2
  66. package/dist/core/report/markdown.js +210 -129
  67. package/dist/core/report/markdown.js.map +1 -1
  68. package/dist/core/robots.d.ts +12 -5
  69. package/dist/core/robots.js +43 -17
  70. package/dist/core/robots.js.map +1 -1
  71. package/dist/core/rules/aeo.d.ts +11 -0
  72. package/dist/core/rules/aeo.js +423 -0
  73. package/dist/core/rules/aeo.js.map +1 -0
  74. package/dist/core/rules/canonical.d.ts +1 -1
  75. package/dist/core/rules/canonical.js +93 -57
  76. package/dist/core/rules/canonical.js.map +1 -1
  77. package/dist/core/rules/content.js +40 -26
  78. package/dist/core/rules/content.js.map +1 -1
  79. package/dist/core/rules/document.js +64 -41
  80. package/dist/core/rules/document.js.map +1 -1
  81. package/dist/core/rules/headings.js +58 -38
  82. package/dist/core/rules/headings.js.map +1 -1
  83. package/dist/core/rules/hreflang.js +70 -38
  84. package/dist/core/rules/hreflang.js.map +1 -1
  85. package/dist/core/rules/images.js +44 -26
  86. package/dist/core/rules/images.js.map +1 -1
  87. package/dist/core/rules/index.d.ts +10 -6
  88. package/dist/core/rules/index.js +43 -36
  89. package/dist/core/rules/index.js.map +1 -1
  90. package/dist/core/rules/indexing.d.ts +2 -2
  91. package/dist/core/rules/indexing.js +48 -26
  92. package/dist/core/rules/indexing.js.map +1 -1
  93. package/dist/core/rules/keyword.js +84 -47
  94. package/dist/core/rules/keyword.js.map +1 -1
  95. package/dist/core/rules/links.js +72 -37
  96. package/dist/core/rules/links.js.map +1 -1
  97. package/dist/core/rules/meta.js +55 -34
  98. package/dist/core/rules/meta.js.map +1 -1
  99. package/dist/core/rules/response.js +75 -41
  100. package/dist/core/rules/response.js.map +1 -1
  101. package/dist/core/rules/rule.d.ts +6 -6
  102. package/dist/core/rules/rule.js +36 -36
  103. package/dist/core/rules/rule.js.map +1 -1
  104. package/dist/core/rules/social.js +86 -45
  105. package/dist/core/rules/social.js.map +1 -1
  106. package/dist/core/rules/structured-data.d.ts +6 -4
  107. package/dist/core/rules/structured-data.js +314 -102
  108. package/dist/core/rules/structured-data.js.map +1 -1
  109. package/dist/core/rules/title.js +48 -33
  110. package/dist/core/rules/title.js.map +1 -1
  111. package/dist/core/score.d.ts +5 -3
  112. package/dist/core/score.js +11 -9
  113. package/dist/core/score.js.map +1 -1
  114. package/dist/core/site-rules.d.ts +3 -3
  115. package/dist/core/site-rules.js +409 -198
  116. package/dist/core/site-rules.js.map +1 -1
  117. package/dist/core/sitemap.d.ts +5 -5
  118. package/dist/core/sitemap.js +109 -64
  119. package/dist/core/sitemap.js.map +1 -1
  120. package/dist/core/static.d.ts +2 -2
  121. package/dist/core/static.js +210 -112
  122. package/dist/core/static.js.map +1 -1
  123. package/dist/core/summary.d.ts +2 -2
  124. package/dist/core/summary.js +11 -6
  125. package/dist/core/summary.js.map +1 -1
  126. package/dist/core/types.d.ts +189 -14
  127. package/dist/core/types.js +15 -8
  128. package/dist/core/types.js.map +1 -1
  129. package/dist/core/url.d.ts +11 -11
  130. package/dist/core/url.js +122 -70
  131. package/dist/core/url.js.map +1 -1
  132. package/dist/index.d.ts +15 -11
  133. package/dist/index.js +11 -9
  134. package/dist/index.js.map +1 -1
  135. package/dist/mcp-bin.js +7 -4
  136. package/dist/mcp-bin.js.map +1 -1
  137. package/dist/mcp.d.ts +3 -3
  138. package/dist/mcp.js +437 -121
  139. package/dist/mcp.js.map +1 -1
  140. package/dist/version.d.ts +0 -1
  141. package/dist/version.js +4 -2
  142. package/dist/version.js.map +1 -1
  143. package/package.json +32 -10
  144. package/skills/seo-audit/SKILL.md +8 -2
@@ -1,251 +1,462 @@
1
- import { finding, GOOGLE_DOCS, cap } from './rules/rule.js';
2
- import { robotsDirectives } from './rules/indexing.js';
3
- import { normalizeForComparison, shingles, jaccard } from './html/text.js';
4
- import { normalizeUrl, normalizePath } from './url.js';
1
+ import { normalizeForComparison, shingles, jaccard } from "./html/text.js";
2
+ import { robotsDirectives } from "./rules/indexing.js";
3
+ import { finding, GOOGLE_DOCS, cap } from "./rules/rule.js";
4
+ import { normalizeUrl, normalizePath } from "./url.js";
5
5
  const meta = (id, severity, description, recommendation, docs) => ({
6
6
  id,
7
- category: 'site',
7
+ category: "site",
8
8
  severity,
9
9
  description,
10
10
  recommendation,
11
11
  docs,
12
- requires: 'crawl',
12
+ requires: "crawl",
13
13
  });
14
14
  export const SITE_RULES = [
15
- meta('site.robots-txt-missing', 'warning', 'No robots.txt was found at the site root.', 'Add /robots.txt. At minimum: "User-agent: *" with no disallow rules, plus a "Sitemap:" line.', GOOGLE_DOCS.robotsTxt),
16
- 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
- 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
- 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),
19
- 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
- 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),
21
- 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
- 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
- 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),
24
- meta('site.sitemap-redirecting-url', 'warning', 'The sitemap lists URLs that redirect.', 'Replace redirecting URLs in the sitemap with their final destination.', GOOGLE_DOCS.sitemaps),
25
- meta('site.not-in-sitemap', 'info', 'Indexable pages discovered by crawling are not listed in the sitemap.', 'Add the missing URLs to the sitemap (or generate it from the router so it stays complete).', GOOGLE_DOCS.sitemaps),
26
- meta('site.orphan-pages', 'info', 'Sitemap URLs that no crawled page links to.', 'Link orphan pages from relevant pages or navigation; pages without internal links get little authority and are crawled less.', GOOGLE_DOCS.links),
27
- meta('site.broken-internal-links', 'error', 'Internal links point to URLs that return 4xx/5xx or fail to load.', 'Fix or remove the links listed in evidence. Each entry shows the broken target and the pages linking to it.', GOOGLE_DOCS.links),
28
- meta('site.redirected-internal-links', 'warning', 'Internal links point to URLs that redirect.', 'Update the links to the final URL to save a round trip for users and crawlers.', GOOGLE_DOCS.redirects),
29
- meta('site.redirect-chains', 'warning', 'Internal URLs go through two or more redirects (or loop).', 'Point each redirect straight at its final destination.', GOOGLE_DOCS.redirects),
30
- meta('site.duplicate-titles', 'warning', 'Several indexable pages share the same <title>.', 'Give each page a unique title that describes that page (templates: include the item name, category and brand).', GOOGLE_DOCS.titles),
31
- meta('site.duplicate-descriptions', 'warning', 'Several indexable pages share the same meta description.', 'Write a unique description per page, or generate it from page-specific data.', GOOGLE_DOCS.snippets),
32
- meta('site.duplicate-h1', 'info', 'Several pages share the same <h1>.', 'Make H1s page-specific.'),
33
- meta('site.duplicate-content', 'warning', 'Pages with identical or near-identical body text.', 'Consolidate duplicates with a canonical to one URL, 301 redirect, or make the content distinct.', GOOGLE_DOCS.canonical),
34
- meta('site.deep-pages', 'info', 'Pages that are more than the configured number of clicks (default 3) from the start URL.', 'Flatten the architecture: link important deep pages from category pages, navigation or the homepage.', GOOGLE_DOCS.links),
35
- meta('site.soft-404', 'warning', 'A clearly non-existent URL returns HTTP 200 instead of 404.', 'Return a real 404 (or 410) status for unknown routes; otherwise search engines index error pages and waste crawl budget.', GOOGLE_DOCS.softs404),
36
- meta('site.http-not-redirected', 'warning', 'The http:// version of the site does not redirect to https://.', 'Add a permanent (301/308) redirect from http to https at the server or CDN.', GOOGLE_DOCS.redirects),
37
- meta('site.host-variant-not-redirected', 'warning', 'The www/non-www variant of the host does not redirect to the main host.', 'Redirect the alternate host (www or bare domain) to the canonical host with a 301/308.', GOOGLE_DOCS.canonical),
38
- 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
- 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
- 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),
41
- 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.'),
15
+ meta("site.robots-txt-missing", "warning", "No robots.txt was found at the site root.", 'Add /robots.txt. At minimum: "User-agent: *" with no disallow rules, plus a "Sitemap:" line.', GOOGLE_DOCS.robotsTxt),
16
+ 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
+ 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
+ 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),
19
+ 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
+ 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),
21
+ 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
+ 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
+ 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),
24
+ meta("site.sitemap-redirecting-url", "warning", "The sitemap lists URLs that redirect.", "Replace redirecting URLs in the sitemap with their final destination.", GOOGLE_DOCS.sitemaps),
25
+ meta("site.not-in-sitemap", "info", "Indexable pages discovered by crawling are not listed in the sitemap.", "Add the missing URLs to the sitemap (or generate it from the router so it stays complete).", GOOGLE_DOCS.sitemaps),
26
+ meta("site.orphan-pages", "info", "Sitemap URLs that no crawled page links to.", "Link orphan pages from relevant pages or navigation; pages without internal links get little authority and are crawled less.", GOOGLE_DOCS.links),
27
+ meta("site.broken-internal-links", "error", "Internal links point to URLs that return 4xx/5xx or fail to load.", "Fix or remove the links listed in evidence. Each entry shows the broken target and the pages linking to it.", GOOGLE_DOCS.links),
28
+ meta("site.redirected-internal-links", "warning", "Internal links point to URLs that redirect.", "Update the links to the final URL to save a round trip for users and crawlers.", GOOGLE_DOCS.redirects),
29
+ meta("site.redirect-chains", "warning", "Internal URLs go through two or more redirects (or loop).", "Point each redirect straight at its final destination.", GOOGLE_DOCS.redirects),
30
+ meta("site.duplicate-titles", "warning", "Several indexable pages share the same <title>.", "Give each page a unique title that describes that page (templates: include the item name, category and brand).", GOOGLE_DOCS.titles),
31
+ meta("site.duplicate-descriptions", "warning", "Several indexable pages share the same meta description.", "Write a unique description per page, or generate it from page-specific data.", GOOGLE_DOCS.snippets),
32
+ meta("site.duplicate-h1", "info", "Several pages share the same <h1>.", "Make H1s page-specific."),
33
+ meta("site.duplicate-content", "warning", "Pages with identical or near-identical body text.", "Consolidate duplicates with a canonical to one URL, 301 redirect, or make the content distinct.", GOOGLE_DOCS.canonical),
34
+ meta("site.deep-pages", "info", "Pages that are more than the configured number of clicks (default 3) from the start URL.", "Flatten the architecture: link important deep pages from category pages, navigation or the homepage.", GOOGLE_DOCS.links),
35
+ meta("site.soft-404", "warning", "A clearly non-existent URL returns HTTP 200 instead of 404.", "Return a real 404 (or 410) status for unknown routes; otherwise search engines index error pages and waste crawl budget.", GOOGLE_DOCS.softs404),
36
+ meta("site.http-not-redirected", "warning", "The http:// version of the site does not redirect to https://.", "Add a permanent (301/308) redirect from http to https at the server or CDN.", GOOGLE_DOCS.redirects),
37
+ meta("site.host-variant-not-redirected", "warning", "The www/non-www variant of the host does not redirect to the main host.", "Redirect the alternate host (www or bare domain) to the canonical host with a 301/308.", GOOGLE_DOCS.canonical),
38
+ 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
+ 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
+ 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),
41
+ 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
42
  ];
43
43
  const byId = new Map(SITE_RULES.map((r) => [r.id, r]));
44
- const m = (id) => byId.get(id);
45
- export function runSiteRules(ctx) {
46
- const out = [];
47
- const key = (url) => (ctx.pathOnly ? pathKey(url) : (normalizeUrl(url) ?? url));
48
- const pagesByKey = new Map();
49
- for (const p of ctx.pages)
50
- pagesByKey.set(key(p.url), p);
51
- const checksByKey = new Map();
52
- for (const [u, c] of ctx.linkChecks)
53
- if (!checksByKey.has(key(u)))
54
- checksByKey.set(key(u), c);
55
- const isIndexable = (p) => !robotsDirectives(p.page, p.response).noindex && (p.response?.status ?? 200) < 400;
56
- const okPages = ctx.pages.filter((p) => (p.response?.status ?? 200) < 400);
57
- /* robots.txt */
58
- if (ctx.robots) {
59
- if (ctx.robots.error || (ctx.robots.status !== null && ctx.robots.status >= 500))
60
- out.push(finding(m('site.robots-txt-error'), `robots.txt at ${ctx.robots.url} could not be read (${ctx.robots.error ?? `HTTP ${ctx.robots.status}`}).`, { evidence: { url: ctx.robots.url, status: ctx.robots.status, error: ctx.robots.error } }));
61
- else if (!ctx.robots.found)
62
- out.push(finding(m('site.robots-txt-missing'), `No robots.txt at ${ctx.robots.url}.`, { severity: ctx.source === 'directory' ? 'info' : 'warning', evidence: { url: ctx.robots.url, status: ctx.robots.status } }));
63
- else if (ctx.robotsAllows && !ctx.robotsAllows(ctx.startUrl))
64
- out.push(finding(m('site.robots-txt-blocks-all'), 'robots.txt blocks the start URL for the crawler user agent.', { evidence: { url: ctx.robots.url, robotsTxt: ctx.robots.text?.slice(0, 500) } }));
65
- }
66
- if (ctx.blockedByRobots.length)
67
- out.push(finding(m('site.robots-blocked-pages'), `${ctx.blockedByRobots.length} linked/sitemap URL(s) are disallowed by robots.txt.`, { evidence: { count: ctx.blockedByRobots.length, examples: cap(ctx.blockedByRobots) } }));
68
- /* sitemaps */
69
- const sitemapKeys = new Set(ctx.sitemaps.urls.map(key));
70
- if (ctx.sitemaps.errors.length)
71
- out.push(finding(m('site.sitemap-error'), `${ctx.sitemaps.errors.length} sitemap problem(s).`, { evidence: { errors: cap(ctx.sitemaps.errors) } }));
72
- if (ctx.sitemaps.sources.length === 0 && ctx.sitemaps.errors.length === 0)
73
- out.push(finding(m('site.sitemap-missing'), 'No sitemap was found.', { severity: ctx.source === 'directory' ? 'info' : 'warning' }));
74
- if (ctx.sitemaps.urlCount > 0) {
75
- const noindex = [];
76
- const nonCanonical = [];
77
- const broken = [];
78
- const redirecting = [];
79
- for (const smUrl of ctx.sitemaps.urls) {
80
- const k = key(smUrl);
81
- const page = pagesByKey.get(k);
82
- if (page) {
83
- if (robotsDirectives(page.page, page.response).noindex)
84
- noindex.push(smUrl);
85
- const canon = page.page.canonical;
86
- if (canon && key(canon) !== k)
87
- nonCanonical.push({ url: smUrl, canonical: canon });
88
- }
89
- const check = checksByKey.get(k);
90
- if (check) {
91
- if ((check.status ?? 0) >= 400 || check.error)
92
- broken.push({ url: smUrl, status: check.status });
93
- else if (check.redirectChain.length > 0)
94
- redirecting.push({ url: smUrl, finalUrl: check.finalUrl });
95
- }
96
- }
97
- if (noindex.length)
98
- out.push(finding(m('site.sitemap-noindex-page'), `${noindex.length} sitemap URL(s) are noindex.`, { evidence: { count: noindex.length, examples: cap(noindex) } }));
99
- if (nonCanonical.length)
100
- out.push(finding(m('site.sitemap-non-canonical'), `${nonCanonical.length} sitemap URL(s) canonicalise to another URL.`, { evidence: { count: nonCanonical.length, examples: cap(nonCanonical) } }));
101
- if (broken.length)
102
- out.push(finding(m('site.sitemap-broken-url'), `${broken.length} sitemap URL(s) return errors.`, { evidence: { count: broken.length, examples: cap(broken) } }));
103
- if (redirecting.length)
104
- out.push(finding(m('site.sitemap-redirecting-url'), `${redirecting.length} sitemap URL(s) redirect.`, { evidence: { count: redirecting.length, examples: cap(redirecting) } }));
105
- const notInSitemap = okPages.filter((p) => isIndexable(p) && !sitemapKeys.has(key(p.url)) && (!p.page.canonical || key(p.page.canonical) === key(p.url))).map((p) => p.url);
106
- if (notInSitemap.length)
107
- out.push(finding(m('site.not-in-sitemap'), `${notInSitemap.length} crawled indexable page(s) are missing from the sitemap.`, { evidence: { count: notInSitemap.length, examples: cap(notInSitemap) } }));
108
- if (!ctx.crawl.hitPageLimit) {
109
- const linked = new Set();
110
- for (const target of ctx.linkSources.keys())
111
- linked.add(key(target));
112
- linked.add(key(ctx.startUrl));
113
- const orphans = ctx.sitemaps.urls.filter((u) => !linked.has(key(u)));
114
- if (orphans.length)
115
- out.push(finding(m('site.orphan-pages'), `${orphans.length} sitemap URL(s) are not linked from any crawled page.`, { evidence: { count: orphans.length, examples: cap(orphans) } }));
116
- }
44
+ const m = (id) => {
45
+ const rule = byId.get(id);
46
+ if (!rule) {
47
+ throw new Error(`Unknown site rule: ${id}`);
117
48
  }
118
- /* link health */
119
- const broken = [];
120
- const redirected = [];
121
- const chains = [];
122
- for (const [k, check] of ctx.linkChecks) {
123
- const sources = [...(ctx.linkSources.get(k) ?? [])];
124
- if (sources.length === 0)
125
- continue;
126
- if (check.error || (check.status ?? 0) >= 400)
127
- broken.push({ url: check.url, status: check.status, error: check.error, linkedFrom: cap(sources, 3) });
128
- else if (check.redirectChain.length >= 2)
129
- chains.push({ url: check.url, hops: check.redirectChain.length, finalUrl: check.finalUrl });
130
- else if (check.redirectChain.length === 1)
131
- redirected.push({ url: check.url, finalUrl: check.finalUrl, status: check.redirectChain[0].status, linkedFrom: cap(sources, 3) });
132
- }
133
- if (broken.length)
134
- out.push(finding(m('site.broken-internal-links'), `${broken.length} internal link target(s) are broken.`, { evidence: { count: broken.length, examples: cap(broken, 20) } }));
135
- if (redirected.length)
136
- out.push(finding(m('site.redirected-internal-links'), `${redirected.length} internal link target(s) redirect.`, { evidence: { count: redirected.length, examples: cap(redirected, 20) } }));
137
- if (chains.length)
138
- out.push(finding(m('site.redirect-chains'), `${chains.length} internal URL(s) have redirect chains.`, { evidence: { count: chains.length, examples: cap(chains) } }));
139
- if (ctx.externalChecks) {
140
- const dead = [];
141
- for (const [u, check] of ctx.externalChecks) {
142
- if (check.error || (check.status ?? 0) >= 400)
143
- dead.push({ url: u, status: check.status, error: check.error, linkedFrom: cap([...(ctx.externalSources?.get(u) ?? [])], 3) });
144
- }
145
- if (dead.length)
146
- out.push(finding(m('site.broken-external-links'), `${dead.length} external link target(s) are broken.`, { evidence: { count: dead.length, examples: cap(dead, 20) } }));
49
+ return rule;
50
+ };
51
+ /** Appends `value` to the list under `k`, starting the list when there is none. */
52
+ const pushTo = (groups, k, value) => {
53
+ const list = groups.get(k);
54
+ if (list) {
55
+ list.push(value);
147
56
  }
148
- if (ctx.unlinkedPages?.length)
149
- out.push(finding(m('site.unlinked-pages'), `${ctx.unlinkedPages.length} HTML file(s) are not linked from any other page.`, { evidence: { count: ctx.unlinkedPages.length, examples: cap(ctx.unlinkedPages) } }));
150
- /* duplicates */
151
- const indexable = okPages.filter(isIndexable);
152
- pushDuplicates(out, m('site.duplicate-titles'), indexable, (p) => p.page.title, 'title');
153
- pushDuplicates(out, m('site.duplicate-descriptions'), indexable, (p) => p.page.metaDescription, 'meta description');
154
- pushDuplicates(out, m('site.duplicate-h1'), indexable, (p) => p.page.h1s[0] ?? null, 'H1');
155
- pushDuplicateContent(out, indexable);
156
- /* architecture */
157
- const deep = okPages.filter((p) => p.depth > ctx.thresholds.deepPageDepth);
158
- if (deep.length)
159
- out.push(finding(m('site.deep-pages'), `${deep.length} page(s) are more than ${ctx.thresholds.deepPageDepth} clicks from the start URL.`, { evidence: { count: deep.length, examples: cap(deep.map((p) => ({ url: p.url, depth: p.depth }))) } }));
160
- /* probes */
161
- if (ctx.probes.soft404 && ctx.probes.soft404.status !== null && ctx.probes.soft404.status < 400)
162
- out.push(finding(m('site.soft-404'), `${ctx.probes.soft404.url} returned HTTP ${ctx.probes.soft404.status}.`, { evidence: ctx.probes.soft404 }));
163
- if (ctx.probes.httpRedirect && ctx.probes.httpRedirect.status !== null && !ctx.probes.httpRedirect.redirectsToHttps)
164
- out.push(finding(m('site.http-not-redirected'), `${ctx.probes.httpRedirect.url} returned HTTP ${ctx.probes.httpRedirect.status} without redirecting to https.`, { evidence: ctx.probes.httpRedirect }));
165
- if (ctx.probes.hostVariant && ctx.probes.hostVariant.status !== null && !ctx.probes.hostVariant.redirectsToMain)
166
- out.push(finding(m('site.host-variant-not-redirected'), `${ctx.probes.hostVariant.url} returned HTTP ${ctx.probes.hostVariant.status} without redirecting to the main host.`, { evidence: ctx.probes.hostVariant }));
167
- /* hreflang return links */
168
- const missingReturn = [];
169
- for (const p of ctx.pages) {
170
- for (const alt of p.page.hreflang) {
171
- if (!alt.url)
172
- continue;
173
- const target = pagesByKey.get(key(alt.url));
174
- if (!target || target === p)
175
- continue;
176
- const linksBack = target.page.hreflang.some((h) => h.url && key(h.url) === key(p.url));
177
- if (!linksBack)
178
- missingReturn.push({ page: p.url, alternate: alt.url });
179
- }
57
+ else {
58
+ groups.set(k, [value]);
180
59
  }
181
- if (missingReturn.length)
182
- out.push(finding(m('site.hreflang-no-return-link'), `${missingReturn.length} hreflang alternate(s) do not link back.`, { evidence: { count: missingReturn.length, examples: cap(missingReturn) } }));
183
- if (ctx.crawl.hitPageLimit)
184
- out.push(finding(m('site.crawl-limit-reached'), `Crawl stopped after ${ctx.crawl.pagesCrawled} pages (limit ${ctx.crawl.maxPages}); ${ctx.crawl.pagesDiscovered} URLs were discovered.`, { evidence: { pagesCrawled: ctx.crawl.pagesCrawled, pagesDiscovered: ctx.crawl.pagesDiscovered, maxPages: ctx.crawl.maxPages } }));
185
- return out;
186
- }
187
- function pathKey(url) {
60
+ };
61
+ const pathKey = (url) => {
188
62
  try {
189
63
  return normalizePath(new URL(url).pathname);
190
64
  }
191
65
  catch {
192
66
  return url;
193
67
  }
194
- }
195
- function pushDuplicates(out, rule, pages, pick, label) {
68
+ };
69
+ const pushDuplicates = (out, rule, pages, pick, label) => {
196
70
  const groups = new Map();
197
71
  for (const p of pages) {
198
72
  const value = pick(p);
199
- if (!value)
73
+ if (!value) {
200
74
  continue;
75
+ }
201
76
  const k = normalizeForComparison(value);
202
- if (!groups.has(k))
203
- groups.set(k, []);
204
- groups.get(k).push(p.url);
77
+ pushTo(groups, k, p.url);
205
78
  }
206
79
  const dupes = [...groups.entries()].filter(([, urls]) => urls.length > 1);
207
- if (!dupes.length)
80
+ if (!dupes.length) {
208
81
  return;
82
+ }
209
83
  const pagesAffected = dupes.reduce((n, [, urls]) => n + urls.length, 0);
210
84
  out.push(finding(rule, `${dupes.length} ${label}(s) are shared by ${pagesAffected} pages.`, {
211
- evidence: { groups: cap(dupes.map(([value, urls]) => ({ value: value.slice(0, 120), pages: cap(urls, 5), count: urls.length }))) },
85
+ evidence: {
86
+ groups: cap(dupes.map(([value, urls]) => ({
87
+ value: value.slice(0, 120),
88
+ pages: cap(urls, 5),
89
+ count: urls.length,
90
+ }))),
91
+ },
212
92
  }));
213
- }
214
- function pushDuplicateContent(out, pages) {
93
+ };
94
+ const pushDuplicateContent = (out, pages) => {
215
95
  const candidates = pages.filter((p) => p.page.wordCount >= 50);
216
96
  const groups = [];
217
97
  const exact = new Map();
218
98
  for (const p of candidates) {
219
99
  const k = normalizeForComparison(p.page.text);
220
- if (!exact.has(k))
221
- exact.set(k, []);
222
- exact.get(k).push(p.url);
100
+ pushTo(exact, k, p.url);
223
101
  }
224
102
  const seen = new Set();
225
103
  for (const urls of exact.values()) {
226
104
  if (urls.length > 1) {
227
105
  groups.push({ pages: urls, similarity: 1 });
228
- urls.forEach((u) => seen.add(u));
106
+ for (const u of urls) {
107
+ seen.add(u);
108
+ }
229
109
  }
230
110
  }
231
111
  if (candidates.length <= 150) {
232
- const sets = candidates.map((p) => ({ url: p.url, set: shingles(p.page.text) }));
233
- for (let i = 0; i < sets.length; i++) {
234
- for (let j = i + 1; j < sets.length; j++) {
235
- const a = sets[i];
236
- const b = sets[j];
237
- if (seen.has(a.url) && seen.has(b.url))
112
+ const sets = candidates.map((p) => ({
113
+ url: p.url,
114
+ set: shingles(p.page.text),
115
+ }));
116
+ for (const [i, a] of sets.entries()) {
117
+ for (const b of sets.slice(i + 1)) {
118
+ if (seen.has(a.url) && seen.has(b.url)) {
238
119
  continue;
120
+ }
239
121
  const sim = jaccard(a.set, b.set);
240
122
  if (sim >= 0.9) {
241
- groups.push({ pages: [a.url, b.url], similarity: Number(sim.toFixed(2)) });
123
+ groups.push({
124
+ pages: [a.url, b.url],
125
+ similarity: Number(sim.toFixed(2)),
126
+ });
242
127
  seen.add(a.url);
243
128
  seen.add(b.url);
244
129
  }
245
130
  }
246
131
  }
247
132
  }
248
- if (groups.length)
249
- out.push(finding(m('site.duplicate-content'), `${groups.length} group(s) of pages have (near-)identical body text.`, { evidence: { groups: cap(groups) } }));
250
- }
133
+ if (groups.length) {
134
+ out.push(finding(m("site.duplicate-content"), `${groups.length} group(s) of pages have (near-)identical body text.`, { evidence: { groups: cap(groups) } }));
135
+ }
136
+ };
137
+ const robotsFindings = (ctx) => {
138
+ const out = [];
139
+ /* robots.txt */
140
+ if (ctx.robots) {
141
+ if (ctx.robots.error ||
142
+ (ctx.robots.status !== null && ctx.robots.status >= 500)) {
143
+ out.push(finding(m("site.robots-txt-error"), `robots.txt at ${ctx.robots.url} could not be read (${ctx.robots.error ?? `HTTP ${ctx.robots.status}`}).`, {
144
+ evidence: {
145
+ url: ctx.robots.url,
146
+ status: ctx.robots.status,
147
+ error: ctx.robots.error,
148
+ },
149
+ }));
150
+ }
151
+ else if (!ctx.robots.found) {
152
+ out.push(finding(m("site.robots-txt-missing"), `No robots.txt at ${ctx.robots.url}.`, {
153
+ severity: ctx.source === "directory" ? "info" : "warning",
154
+ evidence: { url: ctx.robots.url, status: ctx.robots.status },
155
+ }));
156
+ }
157
+ else if (ctx.robotsAllows && !ctx.robotsAllows(ctx.startUrl)) {
158
+ out.push(finding(m("site.robots-txt-blocks-all"), "robots.txt blocks the start URL for the crawler user agent.", {
159
+ evidence: {
160
+ url: ctx.robots.url,
161
+ robotsTxt: ctx.robots.text?.slice(0, 500),
162
+ },
163
+ }));
164
+ }
165
+ }
166
+ if (ctx.blockedByRobots.length) {
167
+ out.push(finding(m("site.robots-blocked-pages"), `${ctx.blockedByRobots.length} linked/sitemap URL(s) are disallowed by robots.txt.`, {
168
+ evidence: {
169
+ count: ctx.blockedByRobots.length,
170
+ examples: cap(ctx.blockedByRobots),
171
+ },
172
+ }));
173
+ }
174
+ return out;
175
+ };
176
+ /** Noindex, non-canonical, broken and redirecting URLs listed in the sitemap. */
177
+ const sitemapUrlFindings = ({ ctx, key, pagesByKey, checksByKey, }) => {
178
+ const out = [];
179
+ const noindex = [];
180
+ const nonCanonical = [];
181
+ const broken = [];
182
+ const redirecting = [];
183
+ for (const smUrl of ctx.sitemaps.urls) {
184
+ const k = key(smUrl);
185
+ const page = pagesByKey.get(k);
186
+ if (page) {
187
+ if (robotsDirectives(page.page, page.response).noindex) {
188
+ noindex.push(smUrl);
189
+ }
190
+ const canon = page.page.canonical;
191
+ if (canon && key(canon) !== k) {
192
+ nonCanonical.push({ url: smUrl, canonical: canon });
193
+ }
194
+ }
195
+ const check = checksByKey.get(k);
196
+ if (check) {
197
+ if ((check.status ?? 0) >= 400 || check.error) {
198
+ broken.push({ url: smUrl, status: check.status });
199
+ }
200
+ else if (check.redirectChain.length > 0) {
201
+ redirecting.push({ url: smUrl, finalUrl: check.finalUrl });
202
+ }
203
+ }
204
+ }
205
+ if (noindex.length) {
206
+ out.push(finding(m("site.sitemap-noindex-page"), `${noindex.length} sitemap URL(s) are noindex.`, { evidence: { count: noindex.length, examples: cap(noindex) } }));
207
+ }
208
+ if (nonCanonical.length) {
209
+ out.push(finding(m("site.sitemap-non-canonical"), `${nonCanonical.length} sitemap URL(s) canonicalise to another URL.`, {
210
+ evidence: {
211
+ count: nonCanonical.length,
212
+ examples: cap(nonCanonical),
213
+ },
214
+ }));
215
+ }
216
+ if (broken.length) {
217
+ out.push(finding(m("site.sitemap-broken-url"), `${broken.length} sitemap URL(s) return errors.`, { evidence: { count: broken.length, examples: cap(broken) } }));
218
+ }
219
+ if (redirecting.length) {
220
+ out.push(finding(m("site.sitemap-redirecting-url"), `${redirecting.length} sitemap URL(s) redirect.`, {
221
+ evidence: { count: redirecting.length, examples: cap(redirecting) },
222
+ }));
223
+ }
224
+ return out;
225
+ };
226
+ /** Indexable pages missing from the sitemap, and sitemap URLs nothing links to. */
227
+ const sitemapCoverageFindings = ({ ctx, key, okPages, isIndexable, }) => {
228
+ const out = [];
229
+ const sitemapKeys = new Set(ctx.sitemaps.urls.map(key));
230
+ const notInSitemap = okPages
231
+ .filter((p) => isIndexable(p) &&
232
+ !sitemapKeys.has(key(p.url)) &&
233
+ (!p.page.canonical || key(p.page.canonical) === key(p.url)))
234
+ .map((p) => p.url);
235
+ if (notInSitemap.length) {
236
+ out.push(finding(m("site.not-in-sitemap"), `${notInSitemap.length} crawled indexable page(s) are missing from the sitemap.`, {
237
+ evidence: {
238
+ count: notInSitemap.length,
239
+ examples: cap(notInSitemap),
240
+ },
241
+ }));
242
+ }
243
+ if (!ctx.crawl.hitPageLimit) {
244
+ const linked = new Set();
245
+ for (const target of ctx.linkSources.keys()) {
246
+ linked.add(key(target));
247
+ }
248
+ linked.add(key(ctx.startUrl));
249
+ const orphans = ctx.sitemaps.urls.filter((u) => !linked.has(key(u)));
250
+ if (orphans.length) {
251
+ out.push(finding(m("site.orphan-pages"), `${orphans.length} sitemap URL(s) are not linked from any crawled page.`, { evidence: { count: orphans.length, examples: cap(orphans) } }));
252
+ }
253
+ }
254
+ return out;
255
+ };
256
+ const sitemapFindings = (scope) => {
257
+ const { ctx } = scope;
258
+ const out = [];
259
+ if (ctx.sitemaps.errors.length) {
260
+ out.push(finding(m("site.sitemap-error"), `${ctx.sitemaps.errors.length} sitemap problem(s).`, { evidence: { errors: cap(ctx.sitemaps.errors) } }));
261
+ }
262
+ if (ctx.sitemaps.sources.length === 0 && ctx.sitemaps.errors.length === 0) {
263
+ out.push(finding(m("site.sitemap-missing"), "No sitemap was found.", {
264
+ severity: ctx.source === "directory" ? "info" : "warning",
265
+ }));
266
+ }
267
+ if (ctx.sitemaps.urlCount > 0) {
268
+ out.push(...sitemapUrlFindings(scope), ...sitemapCoverageFindings(scope));
269
+ }
270
+ return out;
271
+ };
272
+ /** Internal link targets that are broken, redirect once, or redirect in a chain. */
273
+ const linkHealthFindings = (ctx) => {
274
+ const out = [];
275
+ /* link health */
276
+ const broken = [];
277
+ const redirected = [];
278
+ const chains = [];
279
+ for (const [k, check] of ctx.linkChecks) {
280
+ const sources = [...(ctx.linkSources.get(k) ?? [])];
281
+ if (sources.length === 0) {
282
+ continue;
283
+ }
284
+ // Chains of two or more hops are handled first, so a first hop here is the only one.
285
+ const [firstHop] = check.redirectChain;
286
+ if (check.error || (check.status ?? 0) >= 400) {
287
+ broken.push({
288
+ url: check.url,
289
+ status: check.status,
290
+ error: check.error,
291
+ linkedFrom: cap(sources, 3),
292
+ });
293
+ }
294
+ else if (check.redirectChain.length >= 2) {
295
+ chains.push({
296
+ url: check.url,
297
+ hops: check.redirectChain.length,
298
+ finalUrl: check.finalUrl,
299
+ });
300
+ }
301
+ else if (firstHop) {
302
+ redirected.push({
303
+ url: check.url,
304
+ finalUrl: check.finalUrl,
305
+ status: firstHop.status,
306
+ linkedFrom: cap(sources, 3),
307
+ });
308
+ }
309
+ }
310
+ if (broken.length) {
311
+ out.push(finding(m("site.broken-internal-links"), `${broken.length} internal link target(s) are broken.`, { evidence: { count: broken.length, examples: cap(broken, 20) } }));
312
+ }
313
+ if (redirected.length) {
314
+ out.push(finding(m("site.redirected-internal-links"), `${redirected.length} internal link target(s) redirect.`, {
315
+ evidence: { count: redirected.length, examples: cap(redirected, 20) },
316
+ }));
317
+ }
318
+ if (chains.length) {
319
+ out.push(finding(m("site.redirect-chains"), `${chains.length} internal URL(s) have redirect chains.`, { evidence: { count: chains.length, examples: cap(chains) } }));
320
+ }
321
+ return out;
322
+ };
323
+ /** Broken external links (when they were probed) and HTML files nothing links to. */
324
+ const unreachedFindings = (ctx) => {
325
+ const out = [];
326
+ if (ctx.externalChecks) {
327
+ const dead = [];
328
+ for (const [u, check] of ctx.externalChecks) {
329
+ if (check.error || (check.status ?? 0) >= 400) {
330
+ dead.push({
331
+ url: u,
332
+ status: check.status,
333
+ error: check.error,
334
+ linkedFrom: cap([...(ctx.externalSources?.get(u) ?? [])], 3),
335
+ });
336
+ }
337
+ }
338
+ if (dead.length) {
339
+ out.push(finding(m("site.broken-external-links"), `${dead.length} external link target(s) are broken.`, { evidence: { count: dead.length, examples: cap(dead, 20) } }));
340
+ }
341
+ }
342
+ if (ctx.unlinkedPages?.length) {
343
+ out.push(finding(m("site.unlinked-pages"), `${ctx.unlinkedPages.length} HTML file(s) are not linked from any other page.`, {
344
+ evidence: {
345
+ count: ctx.unlinkedPages.length,
346
+ examples: cap(ctx.unlinkedPages),
347
+ },
348
+ }));
349
+ }
350
+ return out;
351
+ };
352
+ const duplicateFindings = ({ okPages, isIndexable }) => {
353
+ const out = [];
354
+ /* duplicates */
355
+ const indexable = okPages.filter(isIndexable);
356
+ pushDuplicates(out, m("site.duplicate-titles"), indexable, (p) => p.page.title, "title");
357
+ pushDuplicates(out, m("site.duplicate-descriptions"), indexable, (p) => p.page.metaDescription, "meta description");
358
+ pushDuplicates(out, m("site.duplicate-h1"), indexable, (p) => p.page.h1s[0] ?? null, "H1");
359
+ pushDuplicateContent(out, indexable);
360
+ return out;
361
+ };
362
+ /** Pages deep in the site, and how the server answers a made-up URL and the other host names. */
363
+ const architectureFindings = ({ ctx, okPages }) => {
364
+ const out = [];
365
+ /* architecture */
366
+ const deep = okPages.filter((p) => p.depth > ctx.thresholds.deepPageDepth);
367
+ if (deep.length) {
368
+ out.push(finding(m("site.deep-pages"), `${deep.length} page(s) are more than ${ctx.thresholds.deepPageDepth} clicks from the start URL.`, {
369
+ evidence: {
370
+ count: deep.length,
371
+ examples: cap(deep.map((p) => ({ url: p.url, depth: p.depth }))),
372
+ },
373
+ }));
374
+ }
375
+ /* probes */
376
+ if (ctx.probes.soft404 &&
377
+ ctx.probes.soft404.status !== null &&
378
+ ctx.probes.soft404.status < 400) {
379
+ out.push(finding(m("site.soft-404"), `${ctx.probes.soft404.url} returned HTTP ${ctx.probes.soft404.status}.`, { evidence: ctx.probes.soft404 }));
380
+ }
381
+ if (ctx.probes.httpRedirect &&
382
+ ctx.probes.httpRedirect.status !== null &&
383
+ !ctx.probes.httpRedirect.redirectsToHttps) {
384
+ out.push(finding(m("site.http-not-redirected"), `${ctx.probes.httpRedirect.url} returned HTTP ${ctx.probes.httpRedirect.status} without redirecting to https.`, { evidence: ctx.probes.httpRedirect }));
385
+ }
386
+ if (ctx.probes.hostVariant &&
387
+ ctx.probes.hostVariant.status !== null &&
388
+ !ctx.probes.hostVariant.redirectsToMain) {
389
+ out.push(finding(m("site.host-variant-not-redirected"), `${ctx.probes.hostVariant.url} returned HTTP ${ctx.probes.hostVariant.status} without redirecting to the main host.`, { evidence: ctx.probes.hostVariant }));
390
+ }
391
+ return out;
392
+ };
393
+ const hreflangFindings = ({ ctx, key, pagesByKey }) => {
394
+ const out = [];
395
+ /* hreflang return links */
396
+ const missingReturn = [];
397
+ for (const p of ctx.pages) {
398
+ for (const alt of p.page.hreflang) {
399
+ if (!alt.url) {
400
+ continue;
401
+ }
402
+ const target = pagesByKey.get(key(alt.url));
403
+ if (!target || target === p) {
404
+ continue;
405
+ }
406
+ const linksBack = target.page.hreflang.some((h) => h.url && key(h.url) === key(p.url));
407
+ if (!linksBack) {
408
+ missingReturn.push({ page: p.url, alternate: alt.url });
409
+ }
410
+ }
411
+ }
412
+ if (missingReturn.length) {
413
+ out.push(finding(m("site.hreflang-no-return-link"), `${missingReturn.length} hreflang alternate(s) do not link back.`, {
414
+ evidence: {
415
+ count: missingReturn.length,
416
+ examples: cap(missingReturn),
417
+ },
418
+ }));
419
+ }
420
+ if (ctx.crawl.hitPageLimit) {
421
+ out.push(finding(m("site.crawl-limit-reached"), `Crawl stopped after ${ctx.crawl.pagesCrawled} pages (limit ${ctx.crawl.maxPages}); ${ctx.crawl.pagesDiscovered} URLs were discovered.`, {
422
+ evidence: {
423
+ pagesCrawled: ctx.crawl.pagesCrawled,
424
+ pagesDiscovered: ctx.crawl.pagesDiscovered,
425
+ maxPages: ctx.crawl.maxPages,
426
+ },
427
+ }));
428
+ }
429
+ return out;
430
+ };
431
+ export const runSiteRules = (ctx) => {
432
+ const key = (url) => ctx.pathOnly ? pathKey(url) : (normalizeUrl(url) ?? url);
433
+ const pagesByKey = new Map();
434
+ for (const p of ctx.pages) {
435
+ pagesByKey.set(key(p.url), p);
436
+ }
437
+ const checksByKey = new Map();
438
+ for (const [u, c] of ctx.linkChecks) {
439
+ if (!checksByKey.has(key(u))) {
440
+ checksByKey.set(key(u), c);
441
+ }
442
+ }
443
+ const scope = {
444
+ ctx,
445
+ key,
446
+ pagesByKey,
447
+ checksByKey,
448
+ okPages: ctx.pages.filter((p) => (p.response?.status ?? 200) < 400),
449
+ isIndexable: (p) => !robotsDirectives(p.page, p.response).noindex &&
450
+ (p.response?.status ?? 200) < 400,
451
+ };
452
+ return [
453
+ ...robotsFindings(ctx),
454
+ ...sitemapFindings(scope),
455
+ ...linkHealthFindings(ctx),
456
+ ...unreachedFindings(ctx),
457
+ ...duplicateFindings(scope),
458
+ ...architectureFindings(scope),
459
+ ...hreflangFindings(scope),
460
+ ];
461
+ };
251
462
  //# sourceMappingURL=site-rules.js.map