seo-crawl-audit 0.1.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 +350 -0
- package/bin/seo-audit.js +12 -0
- package/examples/quotes-toscrape-report.html +383 -0
- package/package.json +46 -0
- package/src/audit.js +125 -0
- package/src/baseline.js +64 -0
- package/src/checkpoint.js +90 -0
- package/src/cli.js +827 -0
- package/src/compare.js +205 -0
- package/src/crawler.js +298 -0
- package/src/html-report.js +434 -0
- package/src/html.js +105 -0
- package/src/report.js +43 -0
- package/src/robots.js +80 -0
- package/src/sitemap.js +209 -0
- package/src/target.js +35 -0
- package/src/urls.js +45 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>SEO baseline audit · SEO Crawl Audit</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
color-scheme: light;
|
|
10
|
+
--bg: #f5f7fb;
|
|
11
|
+
--surface: #ffffff;
|
|
12
|
+
--surface-muted: #f8fafc;
|
|
13
|
+
--text: #172033;
|
|
14
|
+
--muted: #64748b;
|
|
15
|
+
--line: #dbe2ea;
|
|
16
|
+
--accent: #3157d5;
|
|
17
|
+
--error: #b42318;
|
|
18
|
+
--error-bg: #fff0ee;
|
|
19
|
+
--warning: #9a6700;
|
|
20
|
+
--warning-bg: #fff7d6;
|
|
21
|
+
--info: #175cd3;
|
|
22
|
+
--info-bg: #eef4ff;
|
|
23
|
+
--shadow: 0 12px 36px rgba(31, 42, 68, 0.08);
|
|
24
|
+
}
|
|
25
|
+
* { box-sizing: border-box; }
|
|
26
|
+
body {
|
|
27
|
+
margin: 0;
|
|
28
|
+
background: var(--bg);
|
|
29
|
+
color: var(--text);
|
|
30
|
+
font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
31
|
+
}
|
|
32
|
+
main { width: min(1440px, calc(100% - 32px)); margin: 32px auto 64px; }
|
|
33
|
+
header {
|
|
34
|
+
display: flex;
|
|
35
|
+
align-items: flex-start;
|
|
36
|
+
justify-content: space-between;
|
|
37
|
+
gap: 24px;
|
|
38
|
+
margin-bottom: 24px;
|
|
39
|
+
}
|
|
40
|
+
h1 { margin: 0 0 8px; font-size: clamp(26px, 4vw, 40px); line-height: 1.1; letter-spacing: -0.03em; }
|
|
41
|
+
.eyebrow { color: var(--accent); font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
|
|
42
|
+
.meta { color: var(--muted); overflow-wrap: anywhere; }
|
|
43
|
+
.notice {
|
|
44
|
+
margin: 0 0 20px;
|
|
45
|
+
border: 1px solid #b2ccff;
|
|
46
|
+
border-radius: 12px;
|
|
47
|
+
background: var(--info-bg);
|
|
48
|
+
color: #1849a9;
|
|
49
|
+
padding: 12px 14px;
|
|
50
|
+
}
|
|
51
|
+
.cards {
|
|
52
|
+
display: grid;
|
|
53
|
+
grid-template-columns: repeat(5, minmax(130px, 1fr));
|
|
54
|
+
gap: 12px;
|
|
55
|
+
margin-bottom: 20px;
|
|
56
|
+
}
|
|
57
|
+
.card {
|
|
58
|
+
background: var(--surface);
|
|
59
|
+
border: 1px solid var(--line);
|
|
60
|
+
border-radius: 14px;
|
|
61
|
+
padding: 16px;
|
|
62
|
+
box-shadow: var(--shadow);
|
|
63
|
+
}
|
|
64
|
+
.card strong { display: block; font-size: 28px; line-height: 1.1; }
|
|
65
|
+
.card span { color: var(--muted); }
|
|
66
|
+
.card.error strong { color: var(--error); }
|
|
67
|
+
.card.warning strong { color: var(--warning); }
|
|
68
|
+
.card.info strong { color: var(--info); }
|
|
69
|
+
.panel {
|
|
70
|
+
overflow: hidden;
|
|
71
|
+
background: var(--surface);
|
|
72
|
+
border: 1px solid var(--line);
|
|
73
|
+
border-radius: 16px;
|
|
74
|
+
box-shadow: var(--shadow);
|
|
75
|
+
}
|
|
76
|
+
.filters {
|
|
77
|
+
display: grid;
|
|
78
|
+
grid-template-columns: minmax(220px, 2fr) minmax(140px, 1fr) minmax(180px, 1fr) minmax(120px, 0.7fr);
|
|
79
|
+
gap: 12px;
|
|
80
|
+
padding: 16px;
|
|
81
|
+
border-bottom: 1px solid var(--line);
|
|
82
|
+
background: var(--surface-muted);
|
|
83
|
+
}
|
|
84
|
+
input, select {
|
|
85
|
+
width: 100%;
|
|
86
|
+
min-height: 42px;
|
|
87
|
+
border: 1px solid var(--line);
|
|
88
|
+
border-radius: 9px;
|
|
89
|
+
background: var(--surface);
|
|
90
|
+
color: var(--text);
|
|
91
|
+
padding: 9px 11px;
|
|
92
|
+
font: inherit;
|
|
93
|
+
}
|
|
94
|
+
input:focus, select:focus { outline: 3px solid rgba(49, 87, 213, 0.16); border-color: var(--accent); }
|
|
95
|
+
.result-count { padding: 10px 16px; color: var(--muted); border-bottom: 1px solid var(--line); }
|
|
96
|
+
.results-bar {
|
|
97
|
+
display: flex;
|
|
98
|
+
align-items: center;
|
|
99
|
+
justify-content: space-between;
|
|
100
|
+
gap: 12px;
|
|
101
|
+
padding: 10px 16px;
|
|
102
|
+
color: var(--muted);
|
|
103
|
+
border-bottom: 1px solid var(--line);
|
|
104
|
+
}
|
|
105
|
+
.pagination { display: flex; align-items: center; gap: 10px; }
|
|
106
|
+
button {
|
|
107
|
+
border: 1px solid var(--line);
|
|
108
|
+
border-radius: 8px;
|
|
109
|
+
background: var(--surface);
|
|
110
|
+
color: var(--text);
|
|
111
|
+
padding: 6px 10px;
|
|
112
|
+
font: inherit;
|
|
113
|
+
cursor: pointer;
|
|
114
|
+
}
|
|
115
|
+
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
|
|
116
|
+
button:disabled { cursor: not-allowed; opacity: 0.45; }
|
|
117
|
+
.guide {
|
|
118
|
+
display: flex;
|
|
119
|
+
flex-wrap: wrap;
|
|
120
|
+
gap: 8px 18px;
|
|
121
|
+
padding: 10px 16px;
|
|
122
|
+
color: var(--muted);
|
|
123
|
+
border-bottom: 1px solid var(--line);
|
|
124
|
+
font-size: 13px;
|
|
125
|
+
}
|
|
126
|
+
.table-wrap { overflow-x: auto; }
|
|
127
|
+
table { width: 100%; border-collapse: collapse; min-width: 1040px; }
|
|
128
|
+
th {
|
|
129
|
+
position: sticky;
|
|
130
|
+
top: 0;
|
|
131
|
+
z-index: 1;
|
|
132
|
+
padding: 11px 12px;
|
|
133
|
+
background: var(--surface-muted);
|
|
134
|
+
color: var(--muted);
|
|
135
|
+
font-size: 12px;
|
|
136
|
+
text-align: left;
|
|
137
|
+
text-transform: uppercase;
|
|
138
|
+
letter-spacing: 0.05em;
|
|
139
|
+
}
|
|
140
|
+
td { padding: 13px 12px; border-top: 1px solid var(--line); vertical-align: top; }
|
|
141
|
+
tbody tr:hover { background: #fafbff; }
|
|
142
|
+
.badge {
|
|
143
|
+
display: inline-flex;
|
|
144
|
+
border-radius: 999px;
|
|
145
|
+
padding: 3px 8px;
|
|
146
|
+
font-size: 12px;
|
|
147
|
+
font-weight: 700;
|
|
148
|
+
text-transform: uppercase;
|
|
149
|
+
}
|
|
150
|
+
.badge.error { color: var(--error); background: var(--error-bg); }
|
|
151
|
+
.badge.warning { color: var(--warning); background: var(--warning-bg); }
|
|
152
|
+
.badge.info { color: var(--info); background: var(--info-bg); }
|
|
153
|
+
a { color: var(--accent); text-decoration: none; overflow-wrap: anywhere; }
|
|
154
|
+
a:hover { text-decoration: underline; }
|
|
155
|
+
code {
|
|
156
|
+
display: block;
|
|
157
|
+
max-width: 300px;
|
|
158
|
+
overflow-wrap: anywhere;
|
|
159
|
+
white-space: pre-wrap;
|
|
160
|
+
color: #344054;
|
|
161
|
+
font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
162
|
+
}
|
|
163
|
+
.empty { display: none; padding: 48px 20px; color: var(--muted); text-align: center; }
|
|
164
|
+
footer { margin-top: 18px; color: var(--muted); text-align: center; }
|
|
165
|
+
@media (max-width: 900px) {
|
|
166
|
+
main { width: min(100% - 20px, 1440px); margin-top: 20px; }
|
|
167
|
+
header { display: block; }
|
|
168
|
+
.cards { grid-template-columns: repeat(2, 1fr); }
|
|
169
|
+
.filters { grid-template-columns: 1fr; }
|
|
170
|
+
.results-bar { align-items: flex-start; flex-direction: column; }
|
|
171
|
+
}
|
|
172
|
+
</style>
|
|
173
|
+
</head>
|
|
174
|
+
<body>
|
|
175
|
+
<main>
|
|
176
|
+
<header>
|
|
177
|
+
<div>
|
|
178
|
+
<div class="eyebrow">SEO Crawl Audit</div>
|
|
179
|
+
<h1>SEO baseline audit</h1>
|
|
180
|
+
<div class="meta">https://quotes.toscrape.com/</div>
|
|
181
|
+
</div>
|
|
182
|
+
<div class="meta">Generated 2026-07-29T22:47:54.589Z</div>
|
|
183
|
+
</header>
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
<section class="cards" aria-label="Report summary">
|
|
188
|
+
<div class="card"><strong>10</strong><span>Pages checked</span></div>
|
|
189
|
+
<div class="card error"><strong>0</strong><span>Errors</span></div>
|
|
190
|
+
<div class="card warning"><strong>20</strong><span>Warnings</span></div>
|
|
191
|
+
<div class="card info"><strong>0</strong><span>Info</span></div>
|
|
192
|
+
<div class="card"><strong>10</strong><span>Affected pages</span></div>
|
|
193
|
+
</section>
|
|
194
|
+
|
|
195
|
+
<section class="panel">
|
|
196
|
+
<div class="filters">
|
|
197
|
+
<label>
|
|
198
|
+
<span class="meta">Search</span>
|
|
199
|
+
<input id="search" type="search" placeholder="URL, check, or message">
|
|
200
|
+
</label>
|
|
201
|
+
<label>
|
|
202
|
+
<span class="meta">Severity</span>
|
|
203
|
+
<select id="severity">
|
|
204
|
+
<option value="">All severities</option>
|
|
205
|
+
<option value="error">Error</option>
|
|
206
|
+
<option value="warning">Warning</option>
|
|
207
|
+
<option value="info">Info</option>
|
|
208
|
+
</select>
|
|
209
|
+
</label>
|
|
210
|
+
<label>
|
|
211
|
+
<span class="meta">Check</span>
|
|
212
|
+
<select id="rule"><option value="">All checks</option></select>
|
|
213
|
+
</label>
|
|
214
|
+
<label>
|
|
215
|
+
<span class="meta">Rows</span>
|
|
216
|
+
<select id="page-size">
|
|
217
|
+
<option value="100">100 per page</option>
|
|
218
|
+
<option value="250">250 per page</option>
|
|
219
|
+
<option value="500">500 per page</option>
|
|
220
|
+
</select>
|
|
221
|
+
</label>
|
|
222
|
+
</div>
|
|
223
|
+
<div class="guide">
|
|
224
|
+
<span><strong>Errors</strong> need attention first.</span>
|
|
225
|
+
<span><strong>Warnings</strong> are important SEO improvements.</span>
|
|
226
|
+
<span><strong>Info</strong> is useful context.</span>
|
|
227
|
+
</div>
|
|
228
|
+
<div class="results-bar">
|
|
229
|
+
<span id="result-count"></span>
|
|
230
|
+
<div class="pagination">
|
|
231
|
+
<button id="previous" type="button">Previous</button>
|
|
232
|
+
<span id="page-info"></span>
|
|
233
|
+
<button id="next" type="button">Next</button>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
<div class="table-wrap">
|
|
237
|
+
<table>
|
|
238
|
+
<thead>
|
|
239
|
+
<tr>
|
|
240
|
+
<th>Severity</th>
|
|
241
|
+
<th>Check</th>
|
|
242
|
+
<th>Page</th>
|
|
243
|
+
<th>What was found</th>
|
|
244
|
+
<th>Observed value</th>
|
|
245
|
+
</tr>
|
|
246
|
+
</thead>
|
|
247
|
+
<tbody id="issues"></tbody>
|
|
248
|
+
</table>
|
|
249
|
+
</div>
|
|
250
|
+
<div id="empty" class="empty">No issues match the selected filters.</div>
|
|
251
|
+
</section>
|
|
252
|
+
<footer>Generated locally. No report data was uploaded.</footer>
|
|
253
|
+
</main>
|
|
254
|
+
|
|
255
|
+
<script>
|
|
256
|
+
const report = {"mode":"scan","issues":[{"severity":"warning","rule":"missing-canonical","url":"https://quotes.toscrape.com/","message":"Page does not declare a canonical URL","after":null},{"severity":"warning","rule":"missing-description","url":"https://quotes.toscrape.com/","message":"Page does not have a meta description","after":null},{"severity":"warning","rule":"missing-canonical","url":"https://quotes.toscrape.com/author/Albert-Einstein","message":"Page does not declare a canonical URL","after":null},{"severity":"warning","rule":"missing-description","url":"https://quotes.toscrape.com/author/Albert-Einstein","message":"Page does not have a meta description","after":null},{"severity":"warning","rule":"missing-canonical","url":"https://quotes.toscrape.com/author/J-K-Rowling","message":"Page does not declare a canonical URL","after":null},{"severity":"warning","rule":"missing-description","url":"https://quotes.toscrape.com/author/J-K-Rowling","message":"Page does not have a meta description","after":null},{"severity":"warning","rule":"missing-canonical","url":"https://quotes.toscrape.com/login","message":"Page does not declare a canonical URL","after":null},{"severity":"warning","rule":"missing-description","url":"https://quotes.toscrape.com/login","message":"Page does not have a meta description","after":null},{"severity":"warning","rule":"missing-canonical","url":"https://quotes.toscrape.com/tag/abilities/page/1","message":"Page does not declare a canonical URL","after":null},{"severity":"warning","rule":"missing-description","url":"https://quotes.toscrape.com/tag/abilities/page/1","message":"Page does not have a meta description","after":null},{"severity":"warning","rule":"missing-canonical","url":"https://quotes.toscrape.com/tag/change/page/1","message":"Page does not declare a canonical URL","after":null},{"severity":"warning","rule":"missing-description","url":"https://quotes.toscrape.com/tag/change/page/1","message":"Page does not have a meta description","after":null},{"severity":"warning","rule":"missing-canonical","url":"https://quotes.toscrape.com/tag/choices/page/1","message":"Page does not declare a canonical URL","after":null},{"severity":"warning","rule":"missing-description","url":"https://quotes.toscrape.com/tag/choices/page/1","message":"Page does not have a meta description","after":null},{"severity":"warning","rule":"missing-canonical","url":"https://quotes.toscrape.com/tag/deep-thoughts/page/1","message":"Page does not declare a canonical URL","after":null},{"severity":"warning","rule":"missing-description","url":"https://quotes.toscrape.com/tag/deep-thoughts/page/1","message":"Page does not have a meta description","after":null},{"severity":"warning","rule":"missing-canonical","url":"https://quotes.toscrape.com/tag/thinking/page/1","message":"Page does not declare a canonical URL","after":null},{"severity":"warning","rule":"missing-description","url":"https://quotes.toscrape.com/tag/thinking/page/1","message":"Page does not have a meta description","after":null},{"severity":"warning","rule":"missing-canonical","url":"https://quotes.toscrape.com/tag/world/page/1","message":"Page does not declare a canonical URL","after":null},{"severity":"warning","rule":"missing-description","url":"https://quotes.toscrape.com/tag/world/page/1","message":"Page does not have a meta description","after":null}]};
|
|
257
|
+
const search = document.querySelector("#search");
|
|
258
|
+
const severity = document.querySelector("#severity");
|
|
259
|
+
const rule = document.querySelector("#rule");
|
|
260
|
+
const pageSize = document.querySelector("#page-size");
|
|
261
|
+
const tbody = document.querySelector("#issues");
|
|
262
|
+
const empty = document.querySelector("#empty");
|
|
263
|
+
const resultCount = document.querySelector("#result-count");
|
|
264
|
+
const previous = document.querySelector("#previous");
|
|
265
|
+
const next = document.querySelector("#next");
|
|
266
|
+
const pageInfo = document.querySelector("#page-info");
|
|
267
|
+
let currentPage = 1;
|
|
268
|
+
|
|
269
|
+
const rules = [...new Set(report.issues.map((issue) => issue.rule))].sort();
|
|
270
|
+
for (const value of rules) {
|
|
271
|
+
const option = document.createElement("option");
|
|
272
|
+
option.value = value;
|
|
273
|
+
option.textContent = ruleLabel(value);
|
|
274
|
+
rule.append(option);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function valueText(value) {
|
|
278
|
+
if (value === undefined) return "—";
|
|
279
|
+
if (value === null || value === "") return "Not present";
|
|
280
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function ruleLabel(value) {
|
|
284
|
+
const text = value.replaceAll("-", " ");
|
|
285
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function cell(row, value, className) {
|
|
289
|
+
const element = document.createElement("td");
|
|
290
|
+
if (className) element.className = className;
|
|
291
|
+
element.textContent = value;
|
|
292
|
+
row.append(element);
|
|
293
|
+
return element;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function render() {
|
|
297
|
+
const query = search.value.trim().toLowerCase();
|
|
298
|
+
const selectedSeverity = severity.value;
|
|
299
|
+
const selectedRule = rule.value;
|
|
300
|
+
const filtered = report.issues.filter((issue) => {
|
|
301
|
+
if (selectedSeverity && issue.severity !== selectedSeverity) return false;
|
|
302
|
+
if (selectedRule && issue.rule !== selectedRule) return false;
|
|
303
|
+
if (!query) return true;
|
|
304
|
+
return [issue.url, issue.rule, issue.message, valueText(issue.before), valueText(issue.after)]
|
|
305
|
+
.join(" ")
|
|
306
|
+
.toLowerCase()
|
|
307
|
+
.includes(query);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const rowsPerPage = Number(pageSize.value);
|
|
311
|
+
const pageCount = Math.max(1, Math.ceil(filtered.length / rowsPerPage));
|
|
312
|
+
currentPage = Math.min(currentPage, pageCount);
|
|
313
|
+
const firstIndex = (currentPage - 1) * rowsPerPage;
|
|
314
|
+
const visible = filtered.slice(firstIndex, firstIndex + rowsPerPage);
|
|
315
|
+
|
|
316
|
+
tbody.replaceChildren();
|
|
317
|
+
for (const issue of visible) {
|
|
318
|
+
const row = document.createElement("tr");
|
|
319
|
+
|
|
320
|
+
const severityCell = document.createElement("td");
|
|
321
|
+
const badge = document.createElement("span");
|
|
322
|
+
badge.className = "badge " + issue.severity;
|
|
323
|
+
badge.textContent = issue.severity;
|
|
324
|
+
severityCell.append(badge);
|
|
325
|
+
row.append(severityCell);
|
|
326
|
+
|
|
327
|
+
cell(row, ruleLabel(issue.rule));
|
|
328
|
+
|
|
329
|
+
const pageCell = document.createElement("td");
|
|
330
|
+
const link = document.createElement("a");
|
|
331
|
+
link.href = issue.url;
|
|
332
|
+
link.target = "_blank";
|
|
333
|
+
link.rel = "noreferrer";
|
|
334
|
+
link.textContent = issue.url;
|
|
335
|
+
pageCell.append(link);
|
|
336
|
+
row.append(pageCell);
|
|
337
|
+
|
|
338
|
+
cell(row, issue.message);
|
|
339
|
+
|
|
340
|
+
if (report.mode === "check") {
|
|
341
|
+
const beforeCell = document.createElement("td");
|
|
342
|
+
const beforeCode = document.createElement("code");
|
|
343
|
+
beforeCode.textContent = valueText(issue.before);
|
|
344
|
+
beforeCell.append(beforeCode);
|
|
345
|
+
row.append(beforeCell);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const afterCell = document.createElement("td");
|
|
349
|
+
const afterCode = document.createElement("code");
|
|
350
|
+
afterCode.textContent = valueText(issue.after);
|
|
351
|
+
afterCell.append(afterCode);
|
|
352
|
+
row.append(afterCell);
|
|
353
|
+
|
|
354
|
+
tbody.append(row);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
resultCount.textContent = filtered.length.toLocaleString() + " of " + report.issues.length.toLocaleString() + " issue(s)";
|
|
358
|
+
pageInfo.textContent = "Page " + currentPage.toLocaleString() + " of " + pageCount.toLocaleString();
|
|
359
|
+
previous.disabled = currentPage === 1;
|
|
360
|
+
next.disabled = currentPage === pageCount;
|
|
361
|
+
empty.style.display = filtered.length === 0 ? "block" : "none";
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
for (const element of [search, severity, rule, pageSize]) {
|
|
365
|
+
const update = () => {
|
|
366
|
+
currentPage = 1;
|
|
367
|
+
render();
|
|
368
|
+
};
|
|
369
|
+
element.addEventListener("input", update);
|
|
370
|
+
element.addEventListener("change", update);
|
|
371
|
+
}
|
|
372
|
+
previous.addEventListener("click", () => {
|
|
373
|
+
currentPage -= 1;
|
|
374
|
+
render();
|
|
375
|
+
});
|
|
376
|
+
next.addEventListener("click", () => {
|
|
377
|
+
currentPage += 1;
|
|
378
|
+
render();
|
|
379
|
+
});
|
|
380
|
+
render();
|
|
381
|
+
</script>
|
|
382
|
+
</body>
|
|
383
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "seo-crawl-audit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local-first CLI for SEO audits, resumable crawls, HTML reports, and regression checks.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"seo-audit": "bin/seo-audit.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"examples",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --test",
|
|
18
|
+
"check": "node --check bin/seo-audit.js && node --check src/cli.js && node --test"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"seo",
|
|
22
|
+
"seo-audit",
|
|
23
|
+
"crawler",
|
|
24
|
+
"regression-testing",
|
|
25
|
+
"sitemap",
|
|
26
|
+
"html-report",
|
|
27
|
+
"cli",
|
|
28
|
+
"ci"
|
|
29
|
+
],
|
|
30
|
+
"author": "MelnixDev",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/MelnixDev/seo-crawl-audit.git"
|
|
35
|
+
},
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/MelnixDev/seo-crawl-audit/issues"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/MelnixDev/seo-crawl-audit#readme",
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=20.19"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"parse5": "^8.0.0"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/audit.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
function addIssue(issues, severity, rule, page, message, after) {
|
|
2
|
+
issues.push({
|
|
3
|
+
severity,
|
|
4
|
+
rule,
|
|
5
|
+
url: page.url,
|
|
6
|
+
message,
|
|
7
|
+
before: undefined,
|
|
8
|
+
after,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function auditBaseline(baseline) {
|
|
13
|
+
const issues = [];
|
|
14
|
+
|
|
15
|
+
for (const page of baseline.pages) {
|
|
16
|
+
if (page.blockedByRobots) {
|
|
17
|
+
addIssue(
|
|
18
|
+
issues,
|
|
19
|
+
"error",
|
|
20
|
+
"robots-blocked",
|
|
21
|
+
page,
|
|
22
|
+
"Page is blocked by robots.txt",
|
|
23
|
+
true,
|
|
24
|
+
);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (page.error) {
|
|
29
|
+
addIssue(
|
|
30
|
+
issues,
|
|
31
|
+
"error",
|
|
32
|
+
"page-unreachable",
|
|
33
|
+
page,
|
|
34
|
+
`Page request failed: ${page.error}`,
|
|
35
|
+
page.error,
|
|
36
|
+
);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (page.status === null || page.status >= 400) {
|
|
41
|
+
addIssue(
|
|
42
|
+
issues,
|
|
43
|
+
"error",
|
|
44
|
+
"http-error",
|
|
45
|
+
page,
|
|
46
|
+
`Page returned HTTP ${page.status ?? "unknown"}`,
|
|
47
|
+
page.status,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!page.title) {
|
|
52
|
+
addIssue(
|
|
53
|
+
issues,
|
|
54
|
+
"error",
|
|
55
|
+
"missing-title",
|
|
56
|
+
page,
|
|
57
|
+
"Page does not have a title",
|
|
58
|
+
page.title,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!page.description) {
|
|
63
|
+
addIssue(
|
|
64
|
+
issues,
|
|
65
|
+
"warning",
|
|
66
|
+
"missing-description",
|
|
67
|
+
page,
|
|
68
|
+
"Page does not have a meta description",
|
|
69
|
+
page.description,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!page.canonical) {
|
|
74
|
+
addIssue(
|
|
75
|
+
issues,
|
|
76
|
+
"warning",
|
|
77
|
+
"missing-canonical",
|
|
78
|
+
page,
|
|
79
|
+
"Page does not declare a canonical URL",
|
|
80
|
+
page.canonical,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if ((page.h1Count ?? 0) === 0) {
|
|
85
|
+
addIssue(
|
|
86
|
+
issues,
|
|
87
|
+
"warning",
|
|
88
|
+
"missing-h1",
|
|
89
|
+
page,
|
|
90
|
+
"Page does not contain an H1 heading",
|
|
91
|
+
page.h1Count,
|
|
92
|
+
);
|
|
93
|
+
} else if (page.h1Count > 1) {
|
|
94
|
+
addIssue(
|
|
95
|
+
issues,
|
|
96
|
+
"info",
|
|
97
|
+
"multiple-h1",
|
|
98
|
+
page,
|
|
99
|
+
`Page contains ${page.h1Count} H1 headings`,
|
|
100
|
+
page.h1Count,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (/(^|[\s,])noindex($|[\s,])/i.test(page.robots ?? "")) {
|
|
105
|
+
addIssue(
|
|
106
|
+
issues,
|
|
107
|
+
"info",
|
|
108
|
+
"noindex",
|
|
109
|
+
page,
|
|
110
|
+
"Page contains a noindex directive",
|
|
111
|
+
page.robots,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const severityOrder = { error: 0, warning: 1, info: 2 };
|
|
117
|
+
issues.sort(
|
|
118
|
+
(left, right) =>
|
|
119
|
+
severityOrder[left.severity] - severityOrder[right.severity] ||
|
|
120
|
+
left.url.localeCompare(right.url) ||
|
|
121
|
+
left.rule.localeCompare(right.rule),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
return issues;
|
|
125
|
+
}
|
package/src/baseline.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
const SCHEMA_VERSION = 1;
|
|
4
|
+
|
|
5
|
+
function serializePage(page) {
|
|
6
|
+
return {
|
|
7
|
+
url: page.url,
|
|
8
|
+
finalUrl: page.finalUrl,
|
|
9
|
+
status: page.status,
|
|
10
|
+
contentType: page.contentType,
|
|
11
|
+
blockedByRobots: page.blockedByRobots,
|
|
12
|
+
error: page.error,
|
|
13
|
+
title: page.title,
|
|
14
|
+
description: page.description,
|
|
15
|
+
canonical: page.canonical,
|
|
16
|
+
robots: page.robots,
|
|
17
|
+
lang: page.lang,
|
|
18
|
+
h1Count: page.h1Count,
|
|
19
|
+
openGraph: page.openGraph,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createBaseline(scan) {
|
|
24
|
+
return {
|
|
25
|
+
schemaVersion: SCHEMA_VERSION,
|
|
26
|
+
generatedAt: new Date().toISOString(),
|
|
27
|
+
source: {
|
|
28
|
+
startUrl: scan.startUrl,
|
|
29
|
+
maxPages: scan.options.maxPages,
|
|
30
|
+
includeQuery: scan.options.includeQuery,
|
|
31
|
+
respectRobots: scan.options.respectRobots,
|
|
32
|
+
sitemap: scan.options.sitemap,
|
|
33
|
+
},
|
|
34
|
+
sitemap: scan.sitemap
|
|
35
|
+
? {
|
|
36
|
+
url: scan.sitemap.url,
|
|
37
|
+
sitemapCount: scan.sitemap.sitemapCount,
|
|
38
|
+
truncated: scan.sitemap.truncated,
|
|
39
|
+
}
|
|
40
|
+
: null,
|
|
41
|
+
robots: {
|
|
42
|
+
url: scan.robots.url,
|
|
43
|
+
status: scan.robots.status,
|
|
44
|
+
sha256: scan.robots.sha256,
|
|
45
|
+
error: scan.robots.error,
|
|
46
|
+
},
|
|
47
|
+
truncated: scan.truncated,
|
|
48
|
+
pages: scan.pages.map(serializePage),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function writeBaseline(path, baseline) {
|
|
53
|
+
await writeFile(path, `${JSON.stringify(baseline, null, 2)}\n`, "utf8");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function readBaseline(path) {
|
|
57
|
+
const data = JSON.parse(await readFile(path, "utf8"));
|
|
58
|
+
|
|
59
|
+
if (data.schemaVersion !== SCHEMA_VERSION || !Array.isArray(data.pages)) {
|
|
60
|
+
throw new Error(`unsupported or invalid baseline: ${path}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return data;
|
|
64
|
+
}
|