@nurkamol/seo-audit 1.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +792 -0
- package/action.yml +194 -0
- package/bin/seo-audit.mjs +483 -0
- package/package.json +52 -0
- package/src/agents.mjs +122 -0
- package/src/areas.mjs +135 -0
- package/src/audit.mjs +700 -0
- package/src/baseline.mjs +71 -0
- package/src/causes.mjs +167 -0
- package/src/checks.mjs +1253 -0
- package/src/compare.mjs +100 -0
- package/src/config.mjs +156 -0
- package/src/console.mjs +146 -0
- package/src/dupes.mjs +164 -0
- package/src/graph.mjs +89 -0
- package/src/http.mjs +228 -0
- package/src/options.mjs +77 -0
- package/src/parse.mjs +347 -0
- package/src/prompt.mjs +37 -0
- package/src/psi.mjs +200 -0
- package/src/redirects.mjs +145 -0
- package/src/report.mjs +868 -0
- package/src/robots.mjs +92 -0
- package/src/serve.mjs +81 -0
- package/src/site.mjs +714 -0
- package/src/sitemap.mjs +183 -0
package/src/baseline.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Comparing one run against another.
|
|
2
|
+
//
|
|
3
|
+
// "How many warnings does this site have" is rarely the useful question, and
|
|
4
|
+
// after the first pass the answer stops changing. "Did this deploy break
|
|
5
|
+
// something that worked yesterday" is the question worth failing a build over.
|
|
6
|
+
import { causePayload } from './causes.mjs';
|
|
7
|
+
|
|
8
|
+
/** A finding's identity across runs: the check, and where it happened. */
|
|
9
|
+
const key = (f) => `${f.id}\u0000${f.url ?? ''}`;
|
|
10
|
+
|
|
11
|
+
export function diff(previous, current) {
|
|
12
|
+
const before = new Map((previous.findings ?? []).map((f) => [key(f), f]));
|
|
13
|
+
const after = new Map(current.map((f) => [key(f), f]));
|
|
14
|
+
|
|
15
|
+
const added = current.filter((f) => !before.has(key(f)));
|
|
16
|
+
const fixed = [...before.values()].filter((f) => !after.has(key(f)));
|
|
17
|
+
const unchanged = current.length - added.length;
|
|
18
|
+
|
|
19
|
+
return { added, fixed, unchanged, previousDate: previous.meta?.date ?? 'the baseline' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The run as JSON.
|
|
23
|
+
*
|
|
24
|
+
* Two callers with different needs. A **baseline** is committed and diffed in
|
|
25
|
+
* git, so it carries the five fields a finding is identified by and nothing
|
|
26
|
+
* that moves on its own: how many links point at a page changes every time the
|
|
27
|
+
* site does, and a baseline whose git diff churns is a baseline nobody reads.
|
|
28
|
+
* A **report** carries everything the HTML shows, because something has to be
|
|
29
|
+
* able to rebuild it.
|
|
30
|
+
*
|
|
31
|
+
* The shape is versioned either way, so a field added later can never make an
|
|
32
|
+
* old baseline silently mis-compare. */
|
|
33
|
+
export function serialize(findings, meta, { full = false } = {}) {
|
|
34
|
+
return JSON.stringify(
|
|
35
|
+
{
|
|
36
|
+
version: 1,
|
|
37
|
+
meta,
|
|
38
|
+
findings: findings.map(({ level, id, title, detail, url, indexable, reach, traffic }) => ({
|
|
39
|
+
level,
|
|
40
|
+
id,
|
|
41
|
+
title,
|
|
42
|
+
detail,
|
|
43
|
+
url,
|
|
44
|
+
...(full && indexable === false ? { indexable } : {}),
|
|
45
|
+
...(full && reach ? { reach } : {}),
|
|
46
|
+
...(full && traffic ? { traffic } : {}),
|
|
47
|
+
})),
|
|
48
|
+
// A report opens with the work, not the findings, everywhere it is read —
|
|
49
|
+
// so a report that is read by a machine has to carry it too. Deliberately
|
|
50
|
+
// absent from a baseline: grouping is derived from the findings a
|
|
51
|
+
// baseline already holds, and it moves whenever page counts do, which is
|
|
52
|
+
// the churn the baseline shape exists to avoid.
|
|
53
|
+
...(full ? { causes: causePayload(findings, meta.pages ?? 0) } : {}),
|
|
54
|
+
},
|
|
55
|
+
null,
|
|
56
|
+
2,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parse(text, path) {
|
|
61
|
+
let data;
|
|
62
|
+
try {
|
|
63
|
+
data = JSON.parse(text);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
throw new Error(`Baseline is not valid JSON (${path}): ${err.message}`);
|
|
66
|
+
}
|
|
67
|
+
if (!Array.isArray(data.findings)) {
|
|
68
|
+
throw new Error(`Baseline has no findings array (${path}) — is it a --json report?`);
|
|
69
|
+
}
|
|
70
|
+
return data;
|
|
71
|
+
}
|
package/src/causes.mjs
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Findings grouped by the thing that has to change, rather than by the check
|
|
2
|
+
// that noticed it.
|
|
3
|
+
//
|
|
4
|
+
// A real store produced 2,081 findings across 347 URLs, and 1,685 of them were
|
|
5
|
+
// under /products/. Those are not 1,685 problems: they are one Shopify product
|
|
6
|
+
// template, repeated 194 times. Reporting them per check makes the reader
|
|
7
|
+
// derive that themselves, page after page, and most readers stop instead.
|
|
8
|
+
//
|
|
9
|
+
// The rule is deliberately one sentence long: **the same check, on pages of the
|
|
10
|
+
// same section, is one piece of work.** A section is the path a page's template
|
|
11
|
+
// lives under — /products/, /blogs/the-library/, / — because that is how a
|
|
12
|
+
// generated site is actually built, one template per shape of URL. Nothing here
|
|
13
|
+
// guesses at severity or invents a score; it groups, counts, and orders.
|
|
14
|
+
|
|
15
|
+
import { categoryOf } from './areas.mjs';
|
|
16
|
+
|
|
17
|
+
/** The template a URL belongs to: everything up to its last segment.
|
|
18
|
+
*
|
|
19
|
+
* /products/blue-sage → /products/
|
|
20
|
+
* /blogs/the-library/a-post → /blogs/the-library/
|
|
21
|
+
* /about/ → /
|
|
22
|
+
* https://x.test/ → /
|
|
23
|
+
*
|
|
24
|
+
* A trailing slash is not a segment, so /about/ and /about are the same page
|
|
25
|
+
* in the same place, which is the one thing this must not get wrong. */
|
|
26
|
+
export function sectionOf(url) {
|
|
27
|
+
let path;
|
|
28
|
+
try {
|
|
29
|
+
path = new URL(url).pathname;
|
|
30
|
+
} catch {
|
|
31
|
+
return '/';
|
|
32
|
+
}
|
|
33
|
+
const segments = path.split('/').filter(Boolean);
|
|
34
|
+
if (segments.length <= 1) return '/';
|
|
35
|
+
// The last segment names the page; the ones before it name the template — but
|
|
36
|
+
// only the first two of them. Past that a path is usually a date or a
|
|
37
|
+
// taxonomy rather than a different template: jekyllrb.com's /news/2024/01/
|
|
38
|
+
// would otherwise be its own section, one per month, and 1,206 findings
|
|
39
|
+
// arrived as 602 "things to change" instead of a number anybody can act on.
|
|
40
|
+
// Capping at two took its sections from 112 to 27 and left a Shopify store's
|
|
41
|
+
// eight exactly as they were.
|
|
42
|
+
return `/${segments.slice(0, Math.min(segments.length - 1, 2)).join('/')}/`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const WORST_FIRST = { error: 0, warn: 1, info: 2 };
|
|
46
|
+
|
|
47
|
+
/** Findings as pieces of work, worst and widest first.
|
|
48
|
+
*
|
|
49
|
+
* Each cause is `{ id, title, level, section, pages, findings, count }`, where
|
|
50
|
+
* `pages` is the distinct URLs affected and `count` is how many findings the
|
|
51
|
+
* cause accounts for — the two are different when one page trips a check
|
|
52
|
+
* several times.
|
|
53
|
+
*
|
|
54
|
+
* Ordered by level, then by how many pages carry it, then by id so two runs of
|
|
55
|
+
* an unchanged site produce the same report and --baseline stays meaningful. */
|
|
56
|
+
export function byCause(findings) {
|
|
57
|
+
const causes = new Map();
|
|
58
|
+
for (const finding of findings ?? []) {
|
|
59
|
+
const section = sectionOf(finding.url);
|
|
60
|
+
const key = `${finding.id} ${section}`;
|
|
61
|
+
const cause = causes.get(key) ?? {
|
|
62
|
+
id: finding.id,
|
|
63
|
+
title: finding.title,
|
|
64
|
+
level: finding.level,
|
|
65
|
+
section,
|
|
66
|
+
findings: [],
|
|
67
|
+
};
|
|
68
|
+
cause.findings.push(finding);
|
|
69
|
+
// A cause is as serious as the worst thing in it.
|
|
70
|
+
if (WORST_FIRST[finding.level] < WORST_FIRST[cause.level]) {
|
|
71
|
+
cause.level = finding.level;
|
|
72
|
+
cause.title = finding.title;
|
|
73
|
+
}
|
|
74
|
+
causes.set(key, cause);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return [...causes.values()]
|
|
78
|
+
.map((cause) => {
|
|
79
|
+
const pages = [...new Set(cause.findings.map((f) => f.url).filter(Boolean))];
|
|
80
|
+
// How much of the site points at the pages this cause is on, and how
|
|
81
|
+
// close the nearest of them is to the homepage. Both are counts of links
|
|
82
|
+
// that were actually read — nothing is weighted or scored.
|
|
83
|
+
const measured = cause.findings.filter((finding) => finding.reach);
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
let inlinks = 0;
|
|
86
|
+
for (const finding of measured) {
|
|
87
|
+
if (seen.has(finding.url)) continue;
|
|
88
|
+
seen.add(finding.url);
|
|
89
|
+
inlinks += finding.reach.inlinks;
|
|
90
|
+
}
|
|
91
|
+
const depths = measured.map((finding) => finding.reach.depth).filter((d) => d !== null);
|
|
92
|
+
// Impressions are not a proxy for anything: where Search Console has been
|
|
93
|
+
// asked, this is what these pages actually do in Google.
|
|
94
|
+
const shown = new Set();
|
|
95
|
+
let impressions = 0;
|
|
96
|
+
for (const finding of cause.findings) {
|
|
97
|
+
if (!finding.traffic || shown.has(finding.url)) continue;
|
|
98
|
+
shown.add(finding.url);
|
|
99
|
+
impressions += finding.traffic.impressions;
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
...cause,
|
|
103
|
+
pages,
|
|
104
|
+
count: cause.findings.length,
|
|
105
|
+
inlinks: measured.length ? inlinks : null,
|
|
106
|
+
depth: depths.length ? Math.min(...depths) : null,
|
|
107
|
+
impressions: shown.size ? impressions : null,
|
|
108
|
+
};
|
|
109
|
+
})
|
|
110
|
+
.sort(
|
|
111
|
+
(a, b) =>
|
|
112
|
+
WORST_FIRST[a.level] - WORST_FIRST[b.level] ||
|
|
113
|
+
// Measured traffic first where it is known, because it is the only
|
|
114
|
+
// number here that is not a proxy. Then reach before breadth: a
|
|
115
|
+
// template on twenty pages that four hundred links point at is more of
|
|
116
|
+
// the site than one on fifty nobody visits.
|
|
117
|
+
(b.impressions ?? -1) - (a.impressions ?? -1) ||
|
|
118
|
+
(b.inlinks ?? -1) - (a.inlinks ?? -1) ||
|
|
119
|
+
b.pages.length - a.pages.length ||
|
|
120
|
+
a.id.localeCompare(b.id) ||
|
|
121
|
+
a.section.localeCompare(b.section),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One line of English for a cause, used by every report format so they cannot
|
|
126
|
+
* drift: what it is, and how much of the site it is on. */
|
|
127
|
+
export function causeScope(cause, totalPages) {
|
|
128
|
+
const pages = cause.pages.length;
|
|
129
|
+
if (pages <= 1) return cause.section === '/' ? 'once' : `on one page under ${cause.section}`;
|
|
130
|
+
|
|
131
|
+
const where = cause.section === '/' ? 'across the site' : `under ${cause.section}`;
|
|
132
|
+
const share =
|
|
133
|
+
totalPages && pages / totalPages >= 0.5 ? `, ${Math.round((pages / totalPages) * 100)}% of the crawl` : '';
|
|
134
|
+
const seen = cause.impressions
|
|
135
|
+
? `, ${cause.impressions.toLocaleString()} impressions in 28 days`
|
|
136
|
+
: '';
|
|
137
|
+
const reach = seen || (cause.inlinks ? `, ${cause.inlinks.toLocaleString()} links in` : '');
|
|
138
|
+
const near =
|
|
139
|
+
cause.depth === 0 ? ', starting at the homepage' : cause.depth === 1 ? ', one click from home' : '';
|
|
140
|
+
return `${pages} pages ${where}${share}${reach}${near}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The grouping, in the shape a machine reading a report wants.
|
|
144
|
+
*
|
|
145
|
+
* Every front end that emits JSON emits this — the CLI's `--json`, the
|
|
146
|
+
* Worker's `?format=json`, and so the Mac app that reads it. It exists as one
|
|
147
|
+
* function because two of them had assembled it inline and the third had not
|
|
148
|
+
* emitted it at all, which meant a report from the command line and a report
|
|
149
|
+
* from the hosted version were different documents. `scope` is rendered here
|
|
150
|
+
* rather than left to the caller for the same reason: it is the sentence the
|
|
151
|
+
* terminal, the HTML and the app all print, and a second phrasing of it in
|
|
152
|
+
* another language is exactly the drift this project refuses everywhere else. */
|
|
153
|
+
export function causePayload(findings, totalPages) {
|
|
154
|
+
return byCause(findings).map((cause) => ({
|
|
155
|
+
id: cause.id,
|
|
156
|
+
title: cause.title,
|
|
157
|
+
level: cause.level,
|
|
158
|
+
section: cause.section,
|
|
159
|
+
count: cause.count,
|
|
160
|
+
pages: cause.pages,
|
|
161
|
+
scope: causeScope(cause, totalPages),
|
|
162
|
+
// Which part of the site fixes this. The HTML report groups by it, so a
|
|
163
|
+
// client drawing its own report groups by the same thing rather than
|
|
164
|
+
// carrying a second copy of that table in another language.
|
|
165
|
+
area: categoryOf(cause.id),
|
|
166
|
+
}));
|
|
167
|
+
}
|