@rivi-software/agentic-seo-check 0.1.0 → 0.1.1
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 +1 -1
- package/README.md +65 -15
- package/dist/brand.d.ts +11 -0
- package/dist/brand.js +22 -0
- package/dist/brand.js.map +1 -0
- package/dist/cli.js +125 -80
- package/dist/cli.js.map +1 -1
- package/dist/core/audit.d.ts +16 -16
- package/dist/core/audit.js +363 -248
- package/dist/core/audit.js.map +1 -1
- package/dist/core/concurrency.d.ts +4 -4
- package/dist/core/concurrency.js +33 -23
- package/dist/core/concurrency.js.map +1 -1
- package/dist/core/crawl.d.ts +5 -5
- package/dist/core/crawl.js +250 -121
- package/dist/core/crawl.js.map +1 -1
- package/dist/core/fetch.d.ts +5 -5
- package/dist/core/fetch.js +151 -100
- package/dist/core/fetch.js.map +1 -1
- package/dist/core/html/dom.d.ts +13 -0
- package/dist/core/html/dom.js +17 -0
- package/dist/core/html/dom.js.map +1 -0
- package/dist/core/html/extract.d.ts +2 -2
- package/dist/core/html/extract.js +168 -111
- package/dist/core/html/extract.js.map +1 -1
- package/dist/core/html/structured-data.d.ts +16 -6
- package/dist/core/html/structured-data.js +180 -145
- package/dist/core/html/structured-data.js.map +1 -1
- package/dist/core/html/text.d.ts +14 -14
- package/dist/core/html/text.js +110 -107
- package/dist/core/html/text.js.map +1 -1
- package/dist/core/lighthouse.d.ts +26 -25
- package/dist/core/lighthouse.js +181 -105
- package/dist/core/lighthouse.js.map +1 -1
- package/dist/core/performance-rules.d.ts +2 -2
- package/dist/core/performance-rules.js +105 -33
- package/dist/core/performance-rules.js.map +1 -1
- package/dist/core/render-rules.d.ts +5 -4
- package/dist/core/render-rules.js +129 -62
- package/dist/core/render-rules.js.map +1 -1
- package/dist/core/render.d.ts +2 -2
- package/dist/core/render.js +29 -23
- package/dist/core/render.js.map +1 -1
- package/dist/core/report/markdown.d.ts +2 -2
- package/dist/core/report/markdown.js +110 -131
- package/dist/core/report/markdown.js.map +1 -1
- package/dist/core/robots.d.ts +5 -5
- package/dist/core/robots.js +31 -16
- package/dist/core/robots.js.map +1 -1
- package/dist/core/rules/canonical.d.ts +1 -1
- package/dist/core/rules/canonical.js +93 -57
- package/dist/core/rules/canonical.js.map +1 -1
- package/dist/core/rules/content.js +40 -26
- package/dist/core/rules/content.js.map +1 -1
- package/dist/core/rules/document.js +64 -41
- package/dist/core/rules/document.js.map +1 -1
- package/dist/core/rules/headings.js +58 -38
- package/dist/core/rules/headings.js.map +1 -1
- package/dist/core/rules/hreflang.js +70 -38
- package/dist/core/rules/hreflang.js.map +1 -1
- package/dist/core/rules/images.js +44 -26
- package/dist/core/rules/images.js.map +1 -1
- package/dist/core/rules/index.d.ts +6 -6
- package/dist/core/rules/index.js +36 -35
- package/dist/core/rules/index.js.map +1 -1
- package/dist/core/rules/indexing.d.ts +2 -2
- package/dist/core/rules/indexing.js +48 -26
- package/dist/core/rules/indexing.js.map +1 -1
- package/dist/core/rules/keyword.js +84 -47
- package/dist/core/rules/keyword.js.map +1 -1
- package/dist/core/rules/links.js +72 -37
- package/dist/core/rules/links.js.map +1 -1
- package/dist/core/rules/meta.js +55 -34
- package/dist/core/rules/meta.js.map +1 -1
- package/dist/core/rules/response.js +75 -41
- package/dist/core/rules/response.js.map +1 -1
- package/dist/core/rules/rule.d.ts +6 -6
- package/dist/core/rules/rule.js +36 -36
- package/dist/core/rules/rule.js.map +1 -1
- package/dist/core/rules/social.js +86 -45
- package/dist/core/rules/social.js.map +1 -1
- package/dist/core/rules/structured-data.d.ts +6 -4
- package/dist/core/rules/structured-data.js +314 -102
- package/dist/core/rules/structured-data.js.map +1 -1
- package/dist/core/rules/title.js +48 -33
- package/dist/core/rules/title.js.map +1 -1
- package/dist/core/score.d.ts +3 -3
- package/dist/core/score.js +11 -9
- package/dist/core/score.js.map +1 -1
- package/dist/core/site-rules.d.ts +3 -3
- package/dist/core/site-rules.js +409 -198
- package/dist/core/site-rules.js.map +1 -1
- package/dist/core/sitemap.d.ts +5 -5
- package/dist/core/sitemap.js +109 -64
- package/dist/core/sitemap.js.map +1 -1
- package/dist/core/static.d.ts +2 -2
- package/dist/core/static.js +209 -112
- package/dist/core/static.js.map +1 -1
- package/dist/core/summary.d.ts +2 -2
- package/dist/core/summary.js +11 -6
- package/dist/core/summary.js.map +1 -1
- package/dist/core/types.d.ts +14 -14
- package/dist/core/types.js +15 -8
- package/dist/core/types.js.map +1 -1
- package/dist/core/url.d.ts +11 -11
- package/dist/core/url.js +122 -70
- package/dist/core/url.js.map +1 -1
- package/dist/index.d.ts +11 -11
- package/dist/index.js +9 -9
- package/dist/index.js.map +1 -1
- package/dist/mcp-bin.js +7 -4
- package/dist/mcp-bin.js.map +1 -1
- package/dist/mcp.d.ts +3 -3
- package/dist/mcp.js +312 -122
- package/dist/mcp.js.map +1 -1
- package/dist/version.d.ts +0 -1
- package/dist/version.js +4 -2
- package/dist/version.js.map +1 -1
- package/package.json +27 -9
package/dist/core/site-rules.js
CHANGED
|
@@ -1,251 +1,462 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { robotsDirectives } from
|
|
3
|
-
import {
|
|
4
|
-
import { normalizeUrl, normalizePath } from
|
|
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:
|
|
7
|
+
category: "site",
|
|
8
8
|
severity,
|
|
9
9
|
description,
|
|
10
10
|
recommendation,
|
|
11
11
|
docs,
|
|
12
|
-
requires:
|
|
12
|
+
requires: "crawl",
|
|
13
13
|
});
|
|
14
14
|
export const SITE_RULES = [
|
|
15
|
-
meta(
|
|
16
|
-
meta(
|
|
17
|
-
meta(
|
|
18
|
-
meta(
|
|
19
|
-
meta(
|
|
20
|
-
meta(
|
|
21
|
-
meta(
|
|
22
|
-
meta(
|
|
23
|
-
meta(
|
|
24
|
-
meta(
|
|
25
|
-
meta(
|
|
26
|
-
meta(
|
|
27
|
-
meta(
|
|
28
|
-
meta(
|
|
29
|
-
meta(
|
|
30
|
-
meta(
|
|
31
|
-
meta(
|
|
32
|
-
meta(
|
|
33
|
-
meta(
|
|
34
|
-
meta(
|
|
35
|
-
meta(
|
|
36
|
-
meta(
|
|
37
|
-
meta(
|
|
38
|
-
meta(
|
|
39
|
-
meta(
|
|
40
|
-
meta(
|
|
41
|
-
meta(
|
|
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) =>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) => ({
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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({
|
|
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(
|
|
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
|