seo-crawl-audit 0.1.2 → 0.7.2
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/README.md +335 -228
- package/bin/seo-audit.js +18 -3
- package/bundle/cli.js +154 -0
- package/package.json +10 -8
- package/examples/quotes-toscrape-report.html +0 -423
- package/src/audit.js +0 -125
- package/src/baseline.js +0 -65
- package/src/checkpoint.js +0 -90
- package/src/cli.js +0 -850
- package/src/compare.js +0 -205
- package/src/crawler.js +0 -306
- package/src/html-report.js +0 -474
- package/src/html.js +0 -105
- package/src/report.js +0 -43
- package/src/request-gate.js +0 -27
- package/src/robots.js +0 -80
- package/src/sitemap.js +0 -212
- package/src/target.js +0 -35
- package/src/urls.js +0 -45
package/package.json
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "seo-crawl-audit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.7.2",
|
|
4
|
+
"description": "Free, open-source, local-first SEO crawler and regression audit tool.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"seo-audit": "bin/seo-audit.js"
|
|
8
8
|
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./bundle/cli.js"
|
|
11
|
+
},
|
|
9
12
|
"files": [
|
|
10
13
|
"bin",
|
|
11
|
-
"
|
|
12
|
-
"src",
|
|
14
|
+
"bundle",
|
|
13
15
|
"README.md",
|
|
14
16
|
"LICENSE"
|
|
15
17
|
],
|
|
16
18
|
"scripts": {
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
+
"build": "tsc -b",
|
|
20
|
+
"bundle": "esbuild dist/cli.js --bundle --platform=node --target=node20 --format=esm --minify-whitespace --outfile=bundle/cli.js"
|
|
19
21
|
},
|
|
20
22
|
"keywords": [
|
|
21
23
|
"seo",
|
|
@@ -40,7 +42,7 @@
|
|
|
40
42
|
"engines": {
|
|
41
43
|
"node": ">=20.19"
|
|
42
44
|
},
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@seo-crawl-audit/core": "0.7.2"
|
|
45
47
|
}
|
|
46
48
|
}
|
|
@@ -1,423 +0,0 @@
|
|
|
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
|
-
[hidden] { display: none !important; }
|
|
27
|
-
body {
|
|
28
|
-
margin: 0;
|
|
29
|
-
background: var(--bg);
|
|
30
|
-
color: var(--text);
|
|
31
|
-
font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
32
|
-
}
|
|
33
|
-
main { width: min(1440px, calc(100% - 32px)); margin: 32px auto 64px; }
|
|
34
|
-
header {
|
|
35
|
-
display: flex;
|
|
36
|
-
align-items: flex-start;
|
|
37
|
-
justify-content: space-between;
|
|
38
|
-
gap: 24px;
|
|
39
|
-
margin-bottom: 24px;
|
|
40
|
-
}
|
|
41
|
-
h1 { margin: 0 0 8px; font-size: clamp(26px, 4vw, 40px); line-height: 1.1; letter-spacing: -0.03em; }
|
|
42
|
-
.eyebrow { color: var(--accent); font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
|
|
43
|
-
.meta { color: var(--muted); overflow-wrap: anywhere; }
|
|
44
|
-
.notice {
|
|
45
|
-
margin: 0 0 20px;
|
|
46
|
-
border: 1px solid #b2ccff;
|
|
47
|
-
border-radius: 12px;
|
|
48
|
-
background: var(--info-bg);
|
|
49
|
-
color: #1849a9;
|
|
50
|
-
padding: 12px 14px;
|
|
51
|
-
}
|
|
52
|
-
.cards {
|
|
53
|
-
display: grid;
|
|
54
|
-
grid-template-columns: repeat(5, minmax(130px, 1fr));
|
|
55
|
-
gap: 12px;
|
|
56
|
-
margin-bottom: 20px;
|
|
57
|
-
}
|
|
58
|
-
.card {
|
|
59
|
-
background: var(--surface);
|
|
60
|
-
border: 1px solid var(--line);
|
|
61
|
-
border-radius: 14px;
|
|
62
|
-
padding: 16px;
|
|
63
|
-
box-shadow: var(--shadow);
|
|
64
|
-
}
|
|
65
|
-
.card strong { display: block; font-size: 28px; line-height: 1.1; }
|
|
66
|
-
.card span { color: var(--muted); }
|
|
67
|
-
.card.error strong { color: var(--error); }
|
|
68
|
-
.card.warning strong { color: var(--warning); }
|
|
69
|
-
.card.info strong { color: var(--info); }
|
|
70
|
-
.panel {
|
|
71
|
-
overflow: hidden;
|
|
72
|
-
background: var(--surface);
|
|
73
|
-
border: 1px solid var(--line);
|
|
74
|
-
border-radius: 16px;
|
|
75
|
-
box-shadow: var(--shadow);
|
|
76
|
-
}
|
|
77
|
-
.filters {
|
|
78
|
-
display: grid;
|
|
79
|
-
grid-template-columns: minmax(220px, 2fr) minmax(140px, 1fr) minmax(180px, 1fr) minmax(120px, 0.7fr);
|
|
80
|
-
gap: 12px;
|
|
81
|
-
padding: 16px;
|
|
82
|
-
border-bottom: 1px solid var(--line);
|
|
83
|
-
background: var(--surface-muted);
|
|
84
|
-
}
|
|
85
|
-
input, select {
|
|
86
|
-
width: 100%;
|
|
87
|
-
min-height: 42px;
|
|
88
|
-
border: 1px solid var(--line);
|
|
89
|
-
border-radius: 9px;
|
|
90
|
-
background: var(--surface);
|
|
91
|
-
color: var(--text);
|
|
92
|
-
padding: 9px 11px;
|
|
93
|
-
font: inherit;
|
|
94
|
-
}
|
|
95
|
-
input:focus, select:focus { outline: 3px solid rgba(49, 87, 213, 0.16); border-color: var(--accent); }
|
|
96
|
-
.result-count { padding: 10px 16px; color: var(--muted); border-bottom: 1px solid var(--line); }
|
|
97
|
-
.results-bar {
|
|
98
|
-
display: flex;
|
|
99
|
-
align-items: center;
|
|
100
|
-
justify-content: space-between;
|
|
101
|
-
gap: 12px;
|
|
102
|
-
padding: 10px 16px;
|
|
103
|
-
color: var(--muted);
|
|
104
|
-
border-bottom: 1px solid var(--line);
|
|
105
|
-
}
|
|
106
|
-
.pagination { display: flex; align-items: center; gap: 10px; }
|
|
107
|
-
button {
|
|
108
|
-
border: 1px solid var(--line);
|
|
109
|
-
border-radius: 8px;
|
|
110
|
-
background: var(--surface);
|
|
111
|
-
color: var(--text);
|
|
112
|
-
padding: 6px 10px;
|
|
113
|
-
font: inherit;
|
|
114
|
-
cursor: pointer;
|
|
115
|
-
}
|
|
116
|
-
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
|
|
117
|
-
button:disabled { cursor: not-allowed; opacity: 0.45; }
|
|
118
|
-
.guide {
|
|
119
|
-
display: flex;
|
|
120
|
-
flex-wrap: wrap;
|
|
121
|
-
gap: 8px 18px;
|
|
122
|
-
padding: 10px 16px;
|
|
123
|
-
color: var(--muted);
|
|
124
|
-
border-bottom: 1px solid var(--line);
|
|
125
|
-
font-size: 13px;
|
|
126
|
-
}
|
|
127
|
-
.table-wrap { overflow-x: auto; }
|
|
128
|
-
table { width: 100%; border-collapse: collapse; min-width: 1040px; }
|
|
129
|
-
th {
|
|
130
|
-
position: sticky;
|
|
131
|
-
top: 0;
|
|
132
|
-
z-index: 1;
|
|
133
|
-
padding: 11px 12px;
|
|
134
|
-
background: var(--surface-muted);
|
|
135
|
-
color: var(--muted);
|
|
136
|
-
font-size: 12px;
|
|
137
|
-
text-align: left;
|
|
138
|
-
text-transform: uppercase;
|
|
139
|
-
letter-spacing: 0.05em;
|
|
140
|
-
}
|
|
141
|
-
td { padding: 13px 12px; border-top: 1px solid var(--line); vertical-align: top; }
|
|
142
|
-
tbody tr:hover { background: #fafbff; }
|
|
143
|
-
.badge {
|
|
144
|
-
display: inline-flex;
|
|
145
|
-
border-radius: 999px;
|
|
146
|
-
padding: 3px 8px;
|
|
147
|
-
font-size: 12px;
|
|
148
|
-
font-weight: 700;
|
|
149
|
-
text-transform: uppercase;
|
|
150
|
-
}
|
|
151
|
-
.badge.error { color: var(--error); background: var(--error-bg); }
|
|
152
|
-
.badge.warning { color: var(--warning); background: var(--warning-bg); }
|
|
153
|
-
.badge.info { color: var(--info); background: var(--info-bg); }
|
|
154
|
-
a { color: var(--accent); text-decoration: none; overflow-wrap: anywhere; }
|
|
155
|
-
a:hover { text-decoration: underline; }
|
|
156
|
-
code {
|
|
157
|
-
display: block;
|
|
158
|
-
max-width: 300px;
|
|
159
|
-
overflow-wrap: anywhere;
|
|
160
|
-
white-space: pre-wrap;
|
|
161
|
-
color: #344054;
|
|
162
|
-
font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
163
|
-
}
|
|
164
|
-
.empty { padding: 48px 20px; color: var(--muted); text-align: center; }
|
|
165
|
-
.empty strong { display: block; margin-bottom: 4px; color: var(--text); font-size: 16px; }
|
|
166
|
-
.empty button { margin-top: 14px; }
|
|
167
|
-
footer { margin-top: 18px; color: var(--muted); text-align: center; }
|
|
168
|
-
@media (max-width: 900px) {
|
|
169
|
-
main { width: min(100% - 20px, 1440px); margin-top: 20px; }
|
|
170
|
-
header { display: block; }
|
|
171
|
-
.cards { grid-template-columns: repeat(2, 1fr); }
|
|
172
|
-
.card:last-child { grid-column: 1 / -1; }
|
|
173
|
-
.filters { grid-template-columns: 1fr; }
|
|
174
|
-
.results-bar { align-items: flex-start; flex-direction: column; }
|
|
175
|
-
}
|
|
176
|
-
</style>
|
|
177
|
-
</head>
|
|
178
|
-
<body>
|
|
179
|
-
<main>
|
|
180
|
-
<header>
|
|
181
|
-
<div>
|
|
182
|
-
<div class="eyebrow">SEO Crawl Audit</div>
|
|
183
|
-
<h1>SEO baseline audit</h1>
|
|
184
|
-
<div class="meta">https://quotes.toscrape.com/</div>
|
|
185
|
-
</div>
|
|
186
|
-
<div class="meta">Generated <time id="generated-at" datetime="2026-08-02T15:16:04.770Z">2026-08-02T15:16:04.770Z</time></div>
|
|
187
|
-
</header>
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
<section class="cards" aria-label="Report summary">
|
|
192
|
-
<div class="card"><strong>10</strong><span>Pages checked</span></div>
|
|
193
|
-
<div class="card error"><strong>0</strong><span>Errors</span></div>
|
|
194
|
-
<div class="card warning"><strong>20</strong><span>Warnings</span></div>
|
|
195
|
-
<div class="card info"><strong>0</strong><span>Info</span></div>
|
|
196
|
-
<div class="card"><strong>10</strong><span>Affected pages</span></div>
|
|
197
|
-
</section>
|
|
198
|
-
|
|
199
|
-
<section class="panel">
|
|
200
|
-
<div class="filters">
|
|
201
|
-
<label>
|
|
202
|
-
<span class="meta">Search</span>
|
|
203
|
-
<input id="search" type="search" placeholder="URL, check, or message">
|
|
204
|
-
</label>
|
|
205
|
-
<label>
|
|
206
|
-
<span class="meta">Severity</span>
|
|
207
|
-
<select id="severity">
|
|
208
|
-
<option value="">All severities</option>
|
|
209
|
-
<option value="error">Error</option>
|
|
210
|
-
<option value="warning">Warning</option>
|
|
211
|
-
<option value="info">Info</option>
|
|
212
|
-
</select>
|
|
213
|
-
</label>
|
|
214
|
-
<label>
|
|
215
|
-
<span class="meta">Check</span>
|
|
216
|
-
<select id="rule"><option value="">All checks</option></select>
|
|
217
|
-
</label>
|
|
218
|
-
<label>
|
|
219
|
-
<span class="meta">Rows</span>
|
|
220
|
-
<select id="page-size">
|
|
221
|
-
<option value="100">100 per page</option>
|
|
222
|
-
<option value="250">250 per page</option>
|
|
223
|
-
<option value="500">500 per page</option>
|
|
224
|
-
</select>
|
|
225
|
-
</label>
|
|
226
|
-
</div>
|
|
227
|
-
<div class="guide">
|
|
228
|
-
<span><strong>Errors</strong> need attention first.</span>
|
|
229
|
-
<span><strong>Warnings</strong> are important SEO improvements.</span>
|
|
230
|
-
<span><strong>Info</strong> is useful context.</span>
|
|
231
|
-
</div>
|
|
232
|
-
<div class="results-bar">
|
|
233
|
-
<span id="result-count" aria-live="polite"></span>
|
|
234
|
-
<div id="pagination" class="pagination">
|
|
235
|
-
<button id="previous" type="button">Previous</button>
|
|
236
|
-
<span id="page-info" aria-live="polite"></span>
|
|
237
|
-
<button id="next" type="button">Next</button>
|
|
238
|
-
</div>
|
|
239
|
-
</div>
|
|
240
|
-
<div id="table-wrap" class="table-wrap">
|
|
241
|
-
<table>
|
|
242
|
-
<thead>
|
|
243
|
-
<tr>
|
|
244
|
-
<th>Severity</th>
|
|
245
|
-
<th>Check</th>
|
|
246
|
-
<th>Page</th>
|
|
247
|
-
<th>What was found</th>
|
|
248
|
-
<th>Observed value</th>
|
|
249
|
-
</tr>
|
|
250
|
-
</thead>
|
|
251
|
-
<tbody id="issues"></tbody>
|
|
252
|
-
</table>
|
|
253
|
-
</div>
|
|
254
|
-
<div id="empty" class="empty" role="status" hidden>
|
|
255
|
-
<strong id="empty-title"></strong>
|
|
256
|
-
<span id="empty-message"></span>
|
|
257
|
-
<button id="clear-filters" type="button">Clear filters</button>
|
|
258
|
-
</div>
|
|
259
|
-
</section>
|
|
260
|
-
<footer>Generated locally. No report data was uploaded.</footer>
|
|
261
|
-
</main>
|
|
262
|
-
|
|
263
|
-
<script>
|
|
264
|
-
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}]};
|
|
265
|
-
const generatedAt = document.querySelector("#generated-at");
|
|
266
|
-
const search = document.querySelector("#search");
|
|
267
|
-
const severity = document.querySelector("#severity");
|
|
268
|
-
const rule = document.querySelector("#rule");
|
|
269
|
-
const pageSize = document.querySelector("#page-size");
|
|
270
|
-
const tbody = document.querySelector("#issues");
|
|
271
|
-
const empty = document.querySelector("#empty");
|
|
272
|
-
const emptyTitle = document.querySelector("#empty-title");
|
|
273
|
-
const emptyMessage = document.querySelector("#empty-message");
|
|
274
|
-
const clearFilters = document.querySelector("#clear-filters");
|
|
275
|
-
const tableWrap = document.querySelector("#table-wrap");
|
|
276
|
-
const resultCount = document.querySelector("#result-count");
|
|
277
|
-
const pagination = document.querySelector("#pagination");
|
|
278
|
-
const previous = document.querySelector("#previous");
|
|
279
|
-
const next = document.querySelector("#next");
|
|
280
|
-
const pageInfo = document.querySelector("#page-info");
|
|
281
|
-
let currentPage = 1;
|
|
282
|
-
|
|
283
|
-
const generatedDate = new Date(generatedAt.dateTime);
|
|
284
|
-
if (!Number.isNaN(generatedDate.getTime())) {
|
|
285
|
-
generatedAt.textContent = new Intl.DateTimeFormat(undefined, {
|
|
286
|
-
dateStyle: "medium",
|
|
287
|
-
timeStyle: "short",
|
|
288
|
-
}).format(generatedDate);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const rules = [...new Set(report.issues.map((issue) => issue.rule))].sort();
|
|
292
|
-
for (const value of rules) {
|
|
293
|
-
const option = document.createElement("option");
|
|
294
|
-
option.value = value;
|
|
295
|
-
option.textContent = ruleLabel(value);
|
|
296
|
-
rule.append(option);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function valueText(value) {
|
|
300
|
-
if (value === undefined) return "—";
|
|
301
|
-
if (value === null || value === "") return "Not present";
|
|
302
|
-
return typeof value === "string" ? value : JSON.stringify(value);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function ruleLabel(value) {
|
|
306
|
-
const text = value.replaceAll("-", " ");
|
|
307
|
-
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function cell(row, value, className) {
|
|
311
|
-
const element = document.createElement("td");
|
|
312
|
-
if (className) element.className = className;
|
|
313
|
-
element.textContent = value;
|
|
314
|
-
row.append(element);
|
|
315
|
-
return element;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function render() {
|
|
319
|
-
const query = search.value.trim().toLowerCase();
|
|
320
|
-
const selectedSeverity = severity.value;
|
|
321
|
-
const selectedRule = rule.value;
|
|
322
|
-
const filtered = report.issues.filter((issue) => {
|
|
323
|
-
if (selectedSeverity && issue.severity !== selectedSeverity) return false;
|
|
324
|
-
if (selectedRule && issue.rule !== selectedRule) return false;
|
|
325
|
-
if (!query) return true;
|
|
326
|
-
return [issue.url, issue.rule, issue.message, valueText(issue.before), valueText(issue.after)]
|
|
327
|
-
.join(" ")
|
|
328
|
-
.toLowerCase()
|
|
329
|
-
.includes(query);
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
const rowsPerPage = Number(pageSize.value);
|
|
333
|
-
const pageCount = Math.max(1, Math.ceil(filtered.length / rowsPerPage));
|
|
334
|
-
currentPage = Math.min(currentPage, pageCount);
|
|
335
|
-
const firstIndex = (currentPage - 1) * rowsPerPage;
|
|
336
|
-
const visible = filtered.slice(firstIndex, firstIndex + rowsPerPage);
|
|
337
|
-
|
|
338
|
-
tbody.replaceChildren();
|
|
339
|
-
for (const issue of visible) {
|
|
340
|
-
const row = document.createElement("tr");
|
|
341
|
-
|
|
342
|
-
const severityCell = document.createElement("td");
|
|
343
|
-
const badge = document.createElement("span");
|
|
344
|
-
badge.className = "badge " + issue.severity;
|
|
345
|
-
badge.textContent = issue.severity;
|
|
346
|
-
severityCell.append(badge);
|
|
347
|
-
row.append(severityCell);
|
|
348
|
-
|
|
349
|
-
cell(row, ruleLabel(issue.rule));
|
|
350
|
-
|
|
351
|
-
const pageCell = document.createElement("td");
|
|
352
|
-
const link = document.createElement("a");
|
|
353
|
-
link.href = issue.url;
|
|
354
|
-
link.target = "_blank";
|
|
355
|
-
link.rel = "noreferrer";
|
|
356
|
-
link.textContent = issue.url;
|
|
357
|
-
pageCell.append(link);
|
|
358
|
-
row.append(pageCell);
|
|
359
|
-
|
|
360
|
-
cell(row, issue.message);
|
|
361
|
-
|
|
362
|
-
if (report.mode === "check") {
|
|
363
|
-
const beforeCell = document.createElement("td");
|
|
364
|
-
const beforeCode = document.createElement("code");
|
|
365
|
-
beforeCode.textContent = valueText(issue.before);
|
|
366
|
-
beforeCell.append(beforeCode);
|
|
367
|
-
row.append(beforeCell);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
const afterCell = document.createElement("td");
|
|
371
|
-
const afterCode = document.createElement("code");
|
|
372
|
-
afterCode.textContent = valueText(issue.after);
|
|
373
|
-
afterCell.append(afterCode);
|
|
374
|
-
row.append(afterCell);
|
|
375
|
-
|
|
376
|
-
tbody.append(row);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
const issueLabel = report.issues.length === 1 ? "issue" : "issues";
|
|
380
|
-
resultCount.textContent = filtered.length.toLocaleString() + " of " + report.issues.length.toLocaleString() + " " + issueLabel;
|
|
381
|
-
pageInfo.textContent = "Page " + currentPage.toLocaleString() + " of " + pageCount.toLocaleString();
|
|
382
|
-
previous.disabled = currentPage === 1;
|
|
383
|
-
next.disabled = currentPage === pageCount;
|
|
384
|
-
pagination.hidden = filtered.length === 0 || pageCount === 1;
|
|
385
|
-
tableWrap.hidden = filtered.length === 0;
|
|
386
|
-
empty.hidden = filtered.length !== 0;
|
|
387
|
-
if (filtered.length === 0) {
|
|
388
|
-
const reportIsClean = report.issues.length === 0;
|
|
389
|
-
emptyTitle.textContent = reportIsClean ? "No SEO issues found" : "No matching issues";
|
|
390
|
-
emptyMessage.textContent = reportIsClean
|
|
391
|
-
? "The scanned pages passed all current checks."
|
|
392
|
-
: "Try a different search or clear the selected filters.";
|
|
393
|
-
clearFilters.hidden = reportIsClean;
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
for (const element of [search, severity, rule, pageSize]) {
|
|
398
|
-
const update = () => {
|
|
399
|
-
currentPage = 1;
|
|
400
|
-
render();
|
|
401
|
-
};
|
|
402
|
-
element.addEventListener("input", update);
|
|
403
|
-
element.addEventListener("change", update);
|
|
404
|
-
}
|
|
405
|
-
previous.addEventListener("click", () => {
|
|
406
|
-
currentPage -= 1;
|
|
407
|
-
render();
|
|
408
|
-
});
|
|
409
|
-
next.addEventListener("click", () => {
|
|
410
|
-
currentPage += 1;
|
|
411
|
-
render();
|
|
412
|
-
});
|
|
413
|
-
clearFilters.addEventListener("click", () => {
|
|
414
|
-
search.value = "";
|
|
415
|
-
severity.value = "";
|
|
416
|
-
rule.value = "";
|
|
417
|
-
currentPage = 1;
|
|
418
|
-
render();
|
|
419
|
-
});
|
|
420
|
-
render();
|
|
421
|
-
</script>
|
|
422
|
-
</body>
|
|
423
|
-
</html>
|
package/src/audit.js
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
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
|
-
requestDelay: scan.options.requestDelay,
|
|
31
|
-
includeQuery: scan.options.includeQuery,
|
|
32
|
-
respectRobots: scan.options.respectRobots,
|
|
33
|
-
sitemap: scan.options.sitemap,
|
|
34
|
-
},
|
|
35
|
-
sitemap: scan.sitemap
|
|
36
|
-
? {
|
|
37
|
-
url: scan.sitemap.url,
|
|
38
|
-
sitemapCount: scan.sitemap.sitemapCount,
|
|
39
|
-
truncated: scan.sitemap.truncated,
|
|
40
|
-
}
|
|
41
|
-
: null,
|
|
42
|
-
robots: {
|
|
43
|
-
url: scan.robots.url,
|
|
44
|
-
status: scan.robots.status,
|
|
45
|
-
sha256: scan.robots.sha256,
|
|
46
|
-
error: scan.robots.error,
|
|
47
|
-
},
|
|
48
|
-
truncated: scan.truncated,
|
|
49
|
-
pages: scan.pages.map(serializePage),
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export async function writeBaseline(path, baseline) {
|
|
54
|
-
await writeFile(path, `${JSON.stringify(baseline, null, 2)}\n`, "utf8");
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export async function readBaseline(path) {
|
|
58
|
-
const data = JSON.parse(await readFile(path, "utf8"));
|
|
59
|
-
|
|
60
|
-
if (data.schemaVersion !== SCHEMA_VERSION || !Array.isArray(data.pages)) {
|
|
61
|
-
throw new Error(`unsupported or invalid baseline: ${path}`);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return data;
|
|
65
|
-
}
|