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.
@@ -1,474 +0,0 @@
1
- import { rename, writeFile } from "node:fs/promises";
2
-
3
- function escapeHtml(value) {
4
- return String(value)
5
- .replaceAll("&", "&")
6
- .replaceAll("<", "&lt;")
7
- .replaceAll(">", "&gt;")
8
- .replaceAll('"', "&quot;")
9
- .replaceAll("'", "&#039;");
10
- }
11
-
12
- function safeJson(value) {
13
- return JSON.stringify(value)
14
- .replaceAll("<", "\\u003c")
15
- .replaceAll(">", "\\u003e")
16
- .replaceAll("&", "\\u0026");
17
- }
18
-
19
- export function renderHtmlReport({
20
- mode,
21
- startUrl,
22
- generatedAt,
23
- pages,
24
- issues,
25
- partial = false,
26
- targetPages = null,
27
- }) {
28
- const counts = issues.reduce(
29
- (summary, issue) => {
30
- summary[issue.severity] = (summary[issue.severity] ?? 0) + 1;
31
- return summary;
32
- },
33
- { error: 0, warning: 0, info: 0 },
34
- );
35
- const affectedPages = new Set(issues.map((issue) => issue.url)).size;
36
- const reportTitle = partial
37
- ? "Partial SEO scan report"
38
- : mode === "check"
39
- ? "SEO regression report"
40
- : "SEO baseline audit";
41
- const data = safeJson({ mode, issues });
42
- const isCheck = mode === "check";
43
-
44
- return `<!doctype html>
45
- <html lang="en">
46
- <head>
47
- <meta charset="utf-8">
48
- <meta name="viewport" content="width=device-width, initial-scale=1">
49
- <title>${escapeHtml(reportTitle)} · SEO Crawl Audit</title>
50
- <style>
51
- :root {
52
- color-scheme: light;
53
- --bg: #f5f7fb;
54
- --surface: #ffffff;
55
- --surface-muted: #f8fafc;
56
- --text: #172033;
57
- --muted: #64748b;
58
- --line: #dbe2ea;
59
- --accent: #3157d5;
60
- --error: #b42318;
61
- --error-bg: #fff0ee;
62
- --warning: #9a6700;
63
- --warning-bg: #fff7d6;
64
- --info: #175cd3;
65
- --info-bg: #eef4ff;
66
- --shadow: 0 12px 36px rgba(31, 42, 68, 0.08);
67
- }
68
- * { box-sizing: border-box; }
69
- [hidden] { display: none !important; }
70
- body {
71
- margin: 0;
72
- background: var(--bg);
73
- color: var(--text);
74
- font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
75
- }
76
- main { width: min(1440px, calc(100% - 32px)); margin: 32px auto 64px; }
77
- header {
78
- display: flex;
79
- align-items: flex-start;
80
- justify-content: space-between;
81
- gap: 24px;
82
- margin-bottom: 24px;
83
- }
84
- h1 { margin: 0 0 8px; font-size: clamp(26px, 4vw, 40px); line-height: 1.1; letter-spacing: -0.03em; }
85
- .eyebrow { color: var(--accent); font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
86
- .meta { color: var(--muted); overflow-wrap: anywhere; }
87
- .notice {
88
- margin: 0 0 20px;
89
- border: 1px solid #b2ccff;
90
- border-radius: 12px;
91
- background: var(--info-bg);
92
- color: #1849a9;
93
- padding: 12px 14px;
94
- }
95
- .cards {
96
- display: grid;
97
- grid-template-columns: repeat(5, minmax(130px, 1fr));
98
- gap: 12px;
99
- margin-bottom: 20px;
100
- }
101
- .card {
102
- background: var(--surface);
103
- border: 1px solid var(--line);
104
- border-radius: 14px;
105
- padding: 16px;
106
- box-shadow: var(--shadow);
107
- }
108
- .card strong { display: block; font-size: 28px; line-height: 1.1; }
109
- .card span { color: var(--muted); }
110
- .card.error strong { color: var(--error); }
111
- .card.warning strong { color: var(--warning); }
112
- .card.info strong { color: var(--info); }
113
- .panel {
114
- overflow: hidden;
115
- background: var(--surface);
116
- border: 1px solid var(--line);
117
- border-radius: 16px;
118
- box-shadow: var(--shadow);
119
- }
120
- .filters {
121
- display: grid;
122
- grid-template-columns: minmax(220px, 2fr) minmax(140px, 1fr) minmax(180px, 1fr) minmax(120px, 0.7fr);
123
- gap: 12px;
124
- padding: 16px;
125
- border-bottom: 1px solid var(--line);
126
- background: var(--surface-muted);
127
- }
128
- input, select {
129
- width: 100%;
130
- min-height: 42px;
131
- border: 1px solid var(--line);
132
- border-radius: 9px;
133
- background: var(--surface);
134
- color: var(--text);
135
- padding: 9px 11px;
136
- font: inherit;
137
- }
138
- input:focus, select:focus { outline: 3px solid rgba(49, 87, 213, 0.16); border-color: var(--accent); }
139
- .result-count { padding: 10px 16px; color: var(--muted); border-bottom: 1px solid var(--line); }
140
- .results-bar {
141
- display: flex;
142
- align-items: center;
143
- justify-content: space-between;
144
- gap: 12px;
145
- padding: 10px 16px;
146
- color: var(--muted);
147
- border-bottom: 1px solid var(--line);
148
- }
149
- .pagination { display: flex; align-items: center; gap: 10px; }
150
- button {
151
- border: 1px solid var(--line);
152
- border-radius: 8px;
153
- background: var(--surface);
154
- color: var(--text);
155
- padding: 6px 10px;
156
- font: inherit;
157
- cursor: pointer;
158
- }
159
- button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
160
- button:disabled { cursor: not-allowed; opacity: 0.45; }
161
- .guide {
162
- display: flex;
163
- flex-wrap: wrap;
164
- gap: 8px 18px;
165
- padding: 10px 16px;
166
- color: var(--muted);
167
- border-bottom: 1px solid var(--line);
168
- font-size: 13px;
169
- }
170
- .table-wrap { overflow-x: auto; }
171
- table { width: 100%; border-collapse: collapse; min-width: 1040px; }
172
- th {
173
- position: sticky;
174
- top: 0;
175
- z-index: 1;
176
- padding: 11px 12px;
177
- background: var(--surface-muted);
178
- color: var(--muted);
179
- font-size: 12px;
180
- text-align: left;
181
- text-transform: uppercase;
182
- letter-spacing: 0.05em;
183
- }
184
- td { padding: 13px 12px; border-top: 1px solid var(--line); vertical-align: top; }
185
- tbody tr:hover { background: #fafbff; }
186
- .badge {
187
- display: inline-flex;
188
- border-radius: 999px;
189
- padding: 3px 8px;
190
- font-size: 12px;
191
- font-weight: 700;
192
- text-transform: uppercase;
193
- }
194
- .badge.error { color: var(--error); background: var(--error-bg); }
195
- .badge.warning { color: var(--warning); background: var(--warning-bg); }
196
- .badge.info { color: var(--info); background: var(--info-bg); }
197
- a { color: var(--accent); text-decoration: none; overflow-wrap: anywhere; }
198
- a:hover { text-decoration: underline; }
199
- code {
200
- display: block;
201
- max-width: 300px;
202
- overflow-wrap: anywhere;
203
- white-space: pre-wrap;
204
- color: #344054;
205
- font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
206
- }
207
- .empty { padding: 48px 20px; color: var(--muted); text-align: center; }
208
- .empty strong { display: block; margin-bottom: 4px; color: var(--text); font-size: 16px; }
209
- .empty button { margin-top: 14px; }
210
- footer { margin-top: 18px; color: var(--muted); text-align: center; }
211
- @media (max-width: 900px) {
212
- main { width: min(100% - 20px, 1440px); margin-top: 20px; }
213
- header { display: block; }
214
- .cards { grid-template-columns: repeat(2, 1fr); }
215
- .card:last-child { grid-column: 1 / -1; }
216
- .filters { grid-template-columns: 1fr; }
217
- .results-bar { align-items: flex-start; flex-direction: column; }
218
- }
219
- </style>
220
- </head>
221
- <body>
222
- <main>
223
- <header>
224
- <div>
225
- <div class="eyebrow">SEO Crawl Audit</div>
226
- <h1>${escapeHtml(reportTitle)}</h1>
227
- <div class="meta">${escapeHtml(startUrl)}</div>
228
- </div>
229
- <div class="meta">Generated <time id="generated-at" datetime="${escapeHtml(generatedAt)}">${escapeHtml(generatedAt)}</time></div>
230
- </header>
231
-
232
- ${partial ? `<div class="notice"><strong>Partial results.</strong> This report contains the pages saved so far. Run the same scan command again to resume without requesting them twice.</div>` : ""}
233
-
234
- <section class="cards" aria-label="Report summary">
235
- <div class="card"><strong>${pages.length.toLocaleString("en-US")}${targetPages ? ` / ${targetPages.toLocaleString("en-US")}` : ""}</strong><span>Pages checked</span></div>
236
- <div class="card error"><strong>${counts.error}</strong><span>Errors</span></div>
237
- <div class="card warning"><strong>${counts.warning}</strong><span>Warnings</span></div>
238
- <div class="card info"><strong>${counts.info}</strong><span>Info</span></div>
239
- <div class="card"><strong>${affectedPages.toLocaleString("en-US")}</strong><span>Affected pages</span></div>
240
- </section>
241
-
242
- <section class="panel">
243
- <div class="filters">
244
- <label>
245
- <span class="meta">Search</span>
246
- <input id="search" type="search" placeholder="URL, check, or message">
247
- </label>
248
- <label>
249
- <span class="meta">Severity</span>
250
- <select id="severity">
251
- <option value="">All severities</option>
252
- <option value="error">Error</option>
253
- <option value="warning">Warning</option>
254
- <option value="info">Info</option>
255
- </select>
256
- </label>
257
- <label>
258
- <span class="meta">Check</span>
259
- <select id="rule"><option value="">All checks</option></select>
260
- </label>
261
- <label>
262
- <span class="meta">Rows</span>
263
- <select id="page-size">
264
- <option value="100">100 per page</option>
265
- <option value="250">250 per page</option>
266
- <option value="500">500 per page</option>
267
- </select>
268
- </label>
269
- </div>
270
- <div class="guide">
271
- <span><strong>Errors</strong> need attention first.</span>
272
- <span><strong>Warnings</strong> are important SEO improvements.</span>
273
- <span><strong>Info</strong> is useful context.</span>
274
- </div>
275
- <div class="results-bar">
276
- <span id="result-count" aria-live="polite"></span>
277
- <div id="pagination" class="pagination">
278
- <button id="previous" type="button">Previous</button>
279
- <span id="page-info" aria-live="polite"></span>
280
- <button id="next" type="button">Next</button>
281
- </div>
282
- </div>
283
- <div id="table-wrap" class="table-wrap">
284
- <table>
285
- <thead>
286
- <tr>
287
- <th>Severity</th>
288
- <th>Check</th>
289
- <th>Page</th>
290
- <th>${isCheck ? "Change" : "What was found"}</th>
291
- ${isCheck ? "<th>Before</th><th>After</th>" : "<th>Observed value</th>"}
292
- </tr>
293
- </thead>
294
- <tbody id="issues"></tbody>
295
- </table>
296
- </div>
297
- <div id="empty" class="empty" role="status" hidden>
298
- <strong id="empty-title"></strong>
299
- <span id="empty-message"></span>
300
- <button id="clear-filters" type="button">Clear filters</button>
301
- </div>
302
- </section>
303
- <footer>Generated locally. No report data was uploaded.</footer>
304
- </main>
305
-
306
- <script>
307
- const report = ${data};
308
- const generatedAt = document.querySelector("#generated-at");
309
- const search = document.querySelector("#search");
310
- const severity = document.querySelector("#severity");
311
- const rule = document.querySelector("#rule");
312
- const pageSize = document.querySelector("#page-size");
313
- const tbody = document.querySelector("#issues");
314
- const empty = document.querySelector("#empty");
315
- const emptyTitle = document.querySelector("#empty-title");
316
- const emptyMessage = document.querySelector("#empty-message");
317
- const clearFilters = document.querySelector("#clear-filters");
318
- const tableWrap = document.querySelector("#table-wrap");
319
- const resultCount = document.querySelector("#result-count");
320
- const pagination = document.querySelector("#pagination");
321
- const previous = document.querySelector("#previous");
322
- const next = document.querySelector("#next");
323
- const pageInfo = document.querySelector("#page-info");
324
- let currentPage = 1;
325
-
326
- const generatedDate = new Date(generatedAt.dateTime);
327
- if (!Number.isNaN(generatedDate.getTime())) {
328
- generatedAt.textContent = new Intl.DateTimeFormat(undefined, {
329
- dateStyle: "medium",
330
- timeStyle: "short",
331
- }).format(generatedDate);
332
- }
333
-
334
- const rules = [...new Set(report.issues.map((issue) => issue.rule))].sort();
335
- for (const value of rules) {
336
- const option = document.createElement("option");
337
- option.value = value;
338
- option.textContent = ruleLabel(value);
339
- rule.append(option);
340
- }
341
-
342
- function valueText(value) {
343
- if (value === undefined) return "—";
344
- if (value === null || value === "") return "Not present";
345
- return typeof value === "string" ? value : JSON.stringify(value);
346
- }
347
-
348
- function ruleLabel(value) {
349
- const text = value.replaceAll("-", " ");
350
- return text.charAt(0).toUpperCase() + text.slice(1);
351
- }
352
-
353
- function cell(row, value, className) {
354
- const element = document.createElement("td");
355
- if (className) element.className = className;
356
- element.textContent = value;
357
- row.append(element);
358
- return element;
359
- }
360
-
361
- function render() {
362
- const query = search.value.trim().toLowerCase();
363
- const selectedSeverity = severity.value;
364
- const selectedRule = rule.value;
365
- const filtered = report.issues.filter((issue) => {
366
- if (selectedSeverity && issue.severity !== selectedSeverity) return false;
367
- if (selectedRule && issue.rule !== selectedRule) return false;
368
- if (!query) return true;
369
- return [issue.url, issue.rule, issue.message, valueText(issue.before), valueText(issue.after)]
370
- .join(" ")
371
- .toLowerCase()
372
- .includes(query);
373
- });
374
-
375
- const rowsPerPage = Number(pageSize.value);
376
- const pageCount = Math.max(1, Math.ceil(filtered.length / rowsPerPage));
377
- currentPage = Math.min(currentPage, pageCount);
378
- const firstIndex = (currentPage - 1) * rowsPerPage;
379
- const visible = filtered.slice(firstIndex, firstIndex + rowsPerPage);
380
-
381
- tbody.replaceChildren();
382
- for (const issue of visible) {
383
- const row = document.createElement("tr");
384
-
385
- const severityCell = document.createElement("td");
386
- const badge = document.createElement("span");
387
- badge.className = "badge " + issue.severity;
388
- badge.textContent = issue.severity;
389
- severityCell.append(badge);
390
- row.append(severityCell);
391
-
392
- cell(row, ruleLabel(issue.rule));
393
-
394
- const pageCell = document.createElement("td");
395
- const link = document.createElement("a");
396
- link.href = issue.url;
397
- link.target = "_blank";
398
- link.rel = "noreferrer";
399
- link.textContent = issue.url;
400
- pageCell.append(link);
401
- row.append(pageCell);
402
-
403
- cell(row, issue.message);
404
-
405
- if (report.mode === "check") {
406
- const beforeCell = document.createElement("td");
407
- const beforeCode = document.createElement("code");
408
- beforeCode.textContent = valueText(issue.before);
409
- beforeCell.append(beforeCode);
410
- row.append(beforeCell);
411
- }
412
-
413
- const afterCell = document.createElement("td");
414
- const afterCode = document.createElement("code");
415
- afterCode.textContent = valueText(issue.after);
416
- afterCell.append(afterCode);
417
- row.append(afterCell);
418
-
419
- tbody.append(row);
420
- }
421
-
422
- const issueLabel = report.issues.length === 1 ? "issue" : "issues";
423
- resultCount.textContent = filtered.length.toLocaleString() + " of " + report.issues.length.toLocaleString() + " " + issueLabel;
424
- pageInfo.textContent = "Page " + currentPage.toLocaleString() + " of " + pageCount.toLocaleString();
425
- previous.disabled = currentPage === 1;
426
- next.disabled = currentPage === pageCount;
427
- pagination.hidden = filtered.length === 0 || pageCount === 1;
428
- tableWrap.hidden = filtered.length === 0;
429
- empty.hidden = filtered.length !== 0;
430
- if (filtered.length === 0) {
431
- const reportIsClean = report.issues.length === 0;
432
- emptyTitle.textContent = reportIsClean ? "No SEO issues found" : "No matching issues";
433
- emptyMessage.textContent = reportIsClean
434
- ? "The scanned pages passed all current checks."
435
- : "Try a different search or clear the selected filters.";
436
- clearFilters.hidden = reportIsClean;
437
- }
438
- }
439
-
440
- for (const element of [search, severity, rule, pageSize]) {
441
- const update = () => {
442
- currentPage = 1;
443
- render();
444
- };
445
- element.addEventListener("input", update);
446
- element.addEventListener("change", update);
447
- }
448
- previous.addEventListener("click", () => {
449
- currentPage -= 1;
450
- render();
451
- });
452
- next.addEventListener("click", () => {
453
- currentPage += 1;
454
- render();
455
- });
456
- clearFilters.addEventListener("click", () => {
457
- search.value = "";
458
- severity.value = "";
459
- rule.value = "";
460
- currentPage = 1;
461
- render();
462
- });
463
- render();
464
- </script>
465
- </body>
466
- </html>
467
- `;
468
- }
469
-
470
- export async function writeHtmlReport(path, data) {
471
- const temporaryPath = `${path}.${process.pid}.tmp`;
472
- await writeFile(temporaryPath, renderHtmlReport(data), "utf8");
473
- await rename(temporaryPath, path);
474
- }
package/src/html.js DELETED
@@ -1,105 +0,0 @@
1
- import { parse } from "parse5";
2
-
3
- function getAttribute(node, name) {
4
- const attribute = node.attrs?.find(
5
- (item) => item.name.toLowerCase() === name.toLowerCase(),
6
- );
7
- return attribute?.value ?? null;
8
- }
9
-
10
- function getText(node) {
11
- if (node.nodeName === "#text") {
12
- return node.value ?? "";
13
- }
14
-
15
- return (node.childNodes ?? []).map(getText).join("");
16
- }
17
-
18
- function cleanText(value) {
19
- if (value === null || value === undefined) {
20
- return null;
21
- }
22
-
23
- const cleaned = value.replace(/\s+/g, " ").trim();
24
- return cleaned || null;
25
- }
26
-
27
- export function extractSeoData(html) {
28
- const document = parse(html);
29
- const result = {
30
- title: null,
31
- description: null,
32
- canonical: null,
33
- robots: null,
34
- lang: null,
35
- h1Count: 0,
36
- openGraph: {
37
- title: null,
38
- description: null,
39
- image: null,
40
- },
41
- links: [],
42
- };
43
-
44
- function visit(node) {
45
- const tagName = node.tagName?.toLowerCase();
46
-
47
- if (tagName === "html" && result.lang === null) {
48
- result.lang = cleanText(getAttribute(node, "lang"));
49
- }
50
-
51
- if (tagName === "title" && result.title === null) {
52
- result.title = cleanText(getText(node));
53
- }
54
-
55
- if (tagName === "h1") {
56
- result.h1Count += 1;
57
- }
58
-
59
- if (tagName === "meta") {
60
- const name = getAttribute(node, "name")?.toLowerCase();
61
- const property = getAttribute(node, "property")?.toLowerCase();
62
- const content = cleanText(getAttribute(node, "content"));
63
-
64
- if (name === "description" && result.description === null) {
65
- result.description = content;
66
- } else if (name === "robots" && result.robots === null) {
67
- result.robots = content;
68
- } else if (property === "og:title" && result.openGraph.title === null) {
69
- result.openGraph.title = content;
70
- } else if (
71
- property === "og:description" &&
72
- result.openGraph.description === null
73
- ) {
74
- result.openGraph.description = content;
75
- } else if (property === "og:image" && result.openGraph.image === null) {
76
- result.openGraph.image = content;
77
- }
78
- }
79
-
80
- if (tagName === "link") {
81
- const rel = getAttribute(node, "rel")
82
- ?.toLowerCase()
83
- .split(/\s+/)
84
- .filter(Boolean);
85
-
86
- if (rel?.includes("canonical") && result.canonical === null) {
87
- result.canonical = cleanText(getAttribute(node, "href"));
88
- }
89
- }
90
-
91
- if (tagName === "a") {
92
- const href = getAttribute(node, "href");
93
- if (href) {
94
- result.links.push(href);
95
- }
96
- }
97
-
98
- for (const child of node.childNodes ?? []) {
99
- visit(child);
100
- }
101
- }
102
-
103
- visit(document);
104
- return result;
105
- }
package/src/report.js DELETED
@@ -1,43 +0,0 @@
1
- const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
2
-
3
- function color(code, value) {
4
- return useColor ? `\u001b[${code}m${value}\u001b[0m` : value;
5
- }
6
-
7
- export function summarizeIssues(issues) {
8
- return issues.reduce(
9
- (summary, issue) => {
10
- summary[issue.severity] = (summary[issue.severity] ?? 0) + 1;
11
- return summary;
12
- },
13
- { error: 0, warning: 0, info: 0 },
14
- );
15
- }
16
-
17
- export function printIssues(issues) {
18
- if (issues.length === 0) {
19
- console.log(color("32", "✓ No SEO regressions detected."));
20
- return;
21
- }
22
-
23
- for (const issue of issues) {
24
- const marker =
25
- issue.severity === "error"
26
- ? color("31", "✖ error")
27
- : issue.severity === "warning"
28
- ? color("33", "⚠ warning")
29
- : color("34", "ℹ info");
30
- console.log(`${marker} ${issue.rule} ${issue.url}`);
31
- console.log(` ${issue.message}`);
32
-
33
- if (issue.before !== undefined || issue.after !== undefined) {
34
- console.log(` before: ${JSON.stringify(issue.before)}`);
35
- console.log(` after: ${JSON.stringify(issue.after)}`);
36
- }
37
- }
38
-
39
- const summary = summarizeIssues(issues);
40
- console.log(
41
- `\n${summary.error} error(s), ${summary.warning} warning(s) detected.`,
42
- );
43
- }
@@ -1,27 +0,0 @@
1
- import { setTimeout as sleep } from "node:timers/promises";
2
-
3
- export function createRequestGate(
4
- intervalMs = 0,
5
- { now = Date.now, wait = sleep } = {},
6
- ) {
7
- const interval = Math.max(0, intervalMs);
8
- let nextStartAt = 0;
9
- let queue = Promise.resolve();
10
-
11
- return function waitForRequestSlot() {
12
- if (interval === 0) {
13
- return Promise.resolve();
14
- }
15
-
16
- const slot = queue.then(async () => {
17
- const remaining = Math.max(0, nextStartAt - now());
18
- if (remaining > 0) {
19
- await wait(remaining);
20
- }
21
- nextStartAt = now() + interval;
22
- });
23
-
24
- queue = slot.catch(() => {});
25
- return slot;
26
- };
27
- }