seo-crawl-audit 0.1.0 → 0.1.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 +28 -5
- package/examples/quotes-toscrape-report.html +49 -9
- package/package.json +1 -1
- package/src/baseline.js +1 -0
- package/src/cli.js +25 -2
- package/src/crawler.js +9 -1
- package/src/html-report.js +49 -9
- package/src/request-gate.js +27 -0
- package/src/sitemap.js +3 -0
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ your computer in readable JSON and self-contained HTML files.
|
|
|
15
15
|
- a live progress indicator;
|
|
16
16
|
- automatic recovery after an interrupted scan without requesting completed
|
|
17
17
|
pages again;
|
|
18
|
+
- configurable spacing between request starts to reduce load on the site;
|
|
18
19
|
- a partial HTML report that remains useful even if the process is stopped;
|
|
19
20
|
- a saved baseline for later regression checks;
|
|
20
21
|
- exit codes suitable for CI and pull-request checks.
|
|
@@ -239,6 +240,7 @@ are worth reviewing but do not fail a normal check.
|
|
|
239
240
|
--all Scan every URL found in the sitemap
|
|
240
241
|
--max-pages <number> Deprecated alias for --pages
|
|
241
242
|
--concurrency <number> Concurrent requests (default: 5)
|
|
243
|
+
--delay <ms> Delay between request starts (default: 100)
|
|
242
244
|
--timeout <ms> Per-request timeout (default: 10000)
|
|
243
245
|
--sitemap <url> Use a specific sitemap or sitemap index
|
|
244
246
|
--no-sitemap Skip sitemap discovery and crawl internal links
|
|
@@ -258,9 +260,11 @@ in advance.
|
|
|
258
260
|
|
|
259
261
|
### Baseline JSON
|
|
260
262
|
|
|
261
|
-
The baseline records the source configuration,
|
|
262
|
-
the extracted SEO state of every scanned page.
|
|
263
|
-
|
|
263
|
+
The baseline records the source configuration, including the request delay,
|
|
264
|
+
sitemap and robots metadata, and the extracted SEO state of every scanned page.
|
|
265
|
+
`check` reuses that delay unless `--delay` overrides it. The file is
|
|
266
|
+
deterministic enough to review and commit when a team wants versioned regression
|
|
267
|
+
protection.
|
|
264
268
|
|
|
265
269
|
### Checkpoint NDJSON
|
|
266
270
|
|
|
@@ -320,8 +324,27 @@ when a human-readable CI report is useful.
|
|
|
320
324
|
## Responsible crawling
|
|
321
325
|
|
|
322
326
|
The crawler respects `robots.txt` by default, stays on the starting origin,
|
|
323
|
-
|
|
324
|
-
|
|
327
|
+
starts requests at least 100 milliseconds apart, limits concurrency to five
|
|
328
|
+
in-flight requests, and applies a ten-second timeout. The same request gate is
|
|
329
|
+
used for `robots.txt`, sitemap discovery, sitemap files, and HTML pages.
|
|
330
|
+
|
|
331
|
+
Use a longer delay for a smaller site or a server you do not control:
|
|
332
|
+
|
|
333
|
+
```bash
|
|
334
|
+
seo-audit https://example.com/ --delay 500
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
`--delay 500` allows at most two new request starts per second. The default
|
|
338
|
+
`--delay 100` allows at most ten. A value of `0` disables the delay and is best
|
|
339
|
+
reserved for a local or otherwise trusted environment:
|
|
340
|
+
|
|
341
|
+
```bash
|
|
342
|
+
seo-audit http://localhost:3000/ --delay 0
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
A very short value such as `--delay 10` can start up to 100 requests per second,
|
|
346
|
+
so it is not a polite default for a public website. Increase concurrency or
|
|
347
|
+
reduce the delay only on sites you are allowed to test.
|
|
325
348
|
|
|
326
349
|
## Current scope
|
|
327
350
|
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
--shadow: 0 12px 36px rgba(31, 42, 68, 0.08);
|
|
24
24
|
}
|
|
25
25
|
* { box-sizing: border-box; }
|
|
26
|
+
[hidden] { display: none !important; }
|
|
26
27
|
body {
|
|
27
28
|
margin: 0;
|
|
28
29
|
background: var(--bg);
|
|
@@ -160,12 +161,15 @@
|
|
|
160
161
|
color: #344054;
|
|
161
162
|
font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
162
163
|
}
|
|
163
|
-
.empty {
|
|
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; }
|
|
164
167
|
footer { margin-top: 18px; color: var(--muted); text-align: center; }
|
|
165
168
|
@media (max-width: 900px) {
|
|
166
169
|
main { width: min(100% - 20px, 1440px); margin-top: 20px; }
|
|
167
170
|
header { display: block; }
|
|
168
171
|
.cards { grid-template-columns: repeat(2, 1fr); }
|
|
172
|
+
.card:last-child { grid-column: 1 / -1; }
|
|
169
173
|
.filters { grid-template-columns: 1fr; }
|
|
170
174
|
.results-bar { align-items: flex-start; flex-direction: column; }
|
|
171
175
|
}
|
|
@@ -179,7 +183,7 @@
|
|
|
179
183
|
<h1>SEO baseline audit</h1>
|
|
180
184
|
<div class="meta">https://quotes.toscrape.com/</div>
|
|
181
185
|
</div>
|
|
182
|
-
<div class="meta">Generated 2026-
|
|
186
|
+
<div class="meta">Generated <time id="generated-at" datetime="2026-08-02T15:16:04.770Z">2026-08-02T15:16:04.770Z</time></div>
|
|
183
187
|
</header>
|
|
184
188
|
|
|
185
189
|
|
|
@@ -226,14 +230,14 @@
|
|
|
226
230
|
<span><strong>Info</strong> is useful context.</span>
|
|
227
231
|
</div>
|
|
228
232
|
<div class="results-bar">
|
|
229
|
-
<span id="result-count"></span>
|
|
230
|
-
<div class="pagination">
|
|
233
|
+
<span id="result-count" aria-live="polite"></span>
|
|
234
|
+
<div id="pagination" class="pagination">
|
|
231
235
|
<button id="previous" type="button">Previous</button>
|
|
232
|
-
<span id="page-info"></span>
|
|
236
|
+
<span id="page-info" aria-live="polite"></span>
|
|
233
237
|
<button id="next" type="button">Next</button>
|
|
234
238
|
</div>
|
|
235
239
|
</div>
|
|
236
|
-
<div class="table-wrap">
|
|
240
|
+
<div id="table-wrap" class="table-wrap">
|
|
237
241
|
<table>
|
|
238
242
|
<thead>
|
|
239
243
|
<tr>
|
|
@@ -247,25 +251,43 @@
|
|
|
247
251
|
<tbody id="issues"></tbody>
|
|
248
252
|
</table>
|
|
249
253
|
</div>
|
|
250
|
-
<div id="empty" class="empty"
|
|
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>
|
|
251
259
|
</section>
|
|
252
260
|
<footer>Generated locally. No report data was uploaded.</footer>
|
|
253
261
|
</main>
|
|
254
262
|
|
|
255
263
|
<script>
|
|
256
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");
|
|
257
266
|
const search = document.querySelector("#search");
|
|
258
267
|
const severity = document.querySelector("#severity");
|
|
259
268
|
const rule = document.querySelector("#rule");
|
|
260
269
|
const pageSize = document.querySelector("#page-size");
|
|
261
270
|
const tbody = document.querySelector("#issues");
|
|
262
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");
|
|
263
276
|
const resultCount = document.querySelector("#result-count");
|
|
277
|
+
const pagination = document.querySelector("#pagination");
|
|
264
278
|
const previous = document.querySelector("#previous");
|
|
265
279
|
const next = document.querySelector("#next");
|
|
266
280
|
const pageInfo = document.querySelector("#page-info");
|
|
267
281
|
let currentPage = 1;
|
|
268
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
|
+
|
|
269
291
|
const rules = [...new Set(report.issues.map((issue) => issue.rule))].sort();
|
|
270
292
|
for (const value of rules) {
|
|
271
293
|
const option = document.createElement("option");
|
|
@@ -354,11 +376,22 @@
|
|
|
354
376
|
tbody.append(row);
|
|
355
377
|
}
|
|
356
378
|
|
|
357
|
-
|
|
379
|
+
const issueLabel = report.issues.length === 1 ? "issue" : "issues";
|
|
380
|
+
resultCount.textContent = filtered.length.toLocaleString() + " of " + report.issues.length.toLocaleString() + " " + issueLabel;
|
|
358
381
|
pageInfo.textContent = "Page " + currentPage.toLocaleString() + " of " + pageCount.toLocaleString();
|
|
359
382
|
previous.disabled = currentPage === 1;
|
|
360
383
|
next.disabled = currentPage === pageCount;
|
|
361
|
-
|
|
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
|
+
}
|
|
362
395
|
}
|
|
363
396
|
|
|
364
397
|
for (const element of [search, severity, rule, pageSize]) {
|
|
@@ -377,6 +410,13 @@
|
|
|
377
410
|
currentPage += 1;
|
|
378
411
|
render();
|
|
379
412
|
});
|
|
413
|
+
clearFilters.addEventListener("click", () => {
|
|
414
|
+
search.value = "";
|
|
415
|
+
severity.value = "";
|
|
416
|
+
rule.value = "";
|
|
417
|
+
currentPage = 1;
|
|
418
|
+
render();
|
|
419
|
+
});
|
|
380
420
|
render();
|
|
381
421
|
</script>
|
|
382
422
|
</body>
|
package/package.json
CHANGED
package/src/baseline.js
CHANGED
|
@@ -27,6 +27,7 @@ export function createBaseline(scan) {
|
|
|
27
27
|
source: {
|
|
28
28
|
startUrl: scan.startUrl,
|
|
29
29
|
maxPages: scan.options.maxPages,
|
|
30
|
+
requestDelay: scan.options.requestDelay,
|
|
30
31
|
includeQuery: scan.options.includeQuery,
|
|
31
32
|
respectRobots: scan.options.respectRobots,
|
|
32
33
|
sitemap: scan.options.sitemap,
|
package/src/cli.js
CHANGED
|
@@ -17,6 +17,7 @@ import { compareBaselines } from "./compare.js";
|
|
|
17
17
|
import { crawlSite, fetchPages } from "./crawler.js";
|
|
18
18
|
import { writeHtmlReport } from "./html-report.js";
|
|
19
19
|
import { printIssues, summarizeIssues } from "./report.js";
|
|
20
|
+
import { createRequestGate } from "./request-gate.js";
|
|
20
21
|
import {
|
|
21
22
|
discoverSitemapUrl,
|
|
22
23
|
loadSitemapUrls,
|
|
@@ -24,7 +25,7 @@ import {
|
|
|
24
25
|
import { mapUrlToBaseline, mapUrlToTarget } from "./target.js";
|
|
25
26
|
import { normalizeUrl } from "./urls.js";
|
|
26
27
|
|
|
27
|
-
const VERSION = "0.1.
|
|
28
|
+
const VERSION = "0.1.2";
|
|
28
29
|
const DEFAULT_BASELINE = ".seo-audit.json";
|
|
29
30
|
const DEFAULT_REPORT = "seo-audit-report.html";
|
|
30
31
|
|
|
@@ -54,6 +55,7 @@ Options:
|
|
|
54
55
|
--all Scan every URL found in the sitemap
|
|
55
56
|
--max-pages <number> Deprecated alias for --pages
|
|
56
57
|
--concurrency <number> Concurrent requests (default: 5)
|
|
58
|
+
--delay <ms> Delay between request starts (default: 100)
|
|
57
59
|
--timeout <ms> Request timeout in milliseconds (default: 10000)
|
|
58
60
|
--sitemap <url> Seed the crawl from a sitemap or sitemap index
|
|
59
61
|
--no-sitemap Skip sitemap discovery and crawl internal links
|
|
@@ -82,6 +84,18 @@ function positiveInteger(value, name, fallback) {
|
|
|
82
84
|
return parsed;
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
function nonNegativeInteger(value, name, fallback) {
|
|
88
|
+
if (value === undefined) {
|
|
89
|
+
return fallback;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const parsed = Number.parseInt(value, 10);
|
|
93
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
94
|
+
throw new Error(`${name} must be a non-negative integer`);
|
|
95
|
+
}
|
|
96
|
+
return parsed;
|
|
97
|
+
}
|
|
98
|
+
|
|
85
99
|
function parseCliArgs(args) {
|
|
86
100
|
return parseArgs({
|
|
87
101
|
args,
|
|
@@ -97,6 +111,7 @@ function parseCliArgs(args) {
|
|
|
97
111
|
all: { type: "boolean" },
|
|
98
112
|
"max-pages": { type: "string" },
|
|
99
113
|
concurrency: { type: "string" },
|
|
114
|
+
delay: { type: "string" },
|
|
100
115
|
timeout: { type: "string" },
|
|
101
116
|
sitemap: { type: "string" },
|
|
102
117
|
"no-sitemap": { type: "boolean" },
|
|
@@ -147,6 +162,11 @@ function crawlerOptions(values, baseline) {
|
|
|
147
162
|
baseline?.source.maxPages ?? 100,
|
|
148
163
|
),
|
|
149
164
|
concurrency: positiveInteger(values.concurrency, "--concurrency", 5),
|
|
165
|
+
requestDelay: nonNegativeInteger(
|
|
166
|
+
values.delay,
|
|
167
|
+
"--delay",
|
|
168
|
+
baseline?.source.requestDelay ?? 100,
|
|
169
|
+
),
|
|
150
170
|
timeout: positiveInteger(values.timeout, "--timeout", 10_000),
|
|
151
171
|
includeQuery:
|
|
152
172
|
values["include-query"] ?? baseline?.source.includeQuery ?? false,
|
|
@@ -301,7 +321,8 @@ async function resolveSitemap(startUrl, values, options) {
|
|
|
301
321
|
|
|
302
322
|
const discovered = await discoverSitemapUrl(startUrl, {
|
|
303
323
|
timeout: options.timeout,
|
|
304
|
-
userAgent: "seo-crawl-audit/0.1.
|
|
324
|
+
userAgent: "seo-crawl-audit/0.1.2",
|
|
325
|
+
requestGate: options.requestGate,
|
|
305
326
|
});
|
|
306
327
|
if (discovered) {
|
|
307
328
|
if (!values.json) {
|
|
@@ -327,6 +348,7 @@ async function scanCommand(url, values) {
|
|
|
327
348
|
|
|
328
349
|
const output = resolve(values.output ?? DEFAULT_BASELINE);
|
|
329
350
|
const options = crawlerOptions(values);
|
|
351
|
+
options.requestGate = createRequestGate(options.requestDelay);
|
|
330
352
|
const normalizedStartUrl = normalizeUrl(url, undefined, {
|
|
331
353
|
includeQuery: options.includeQuery,
|
|
332
354
|
});
|
|
@@ -632,6 +654,7 @@ async function checkCommand(url, values) {
|
|
|
632
654
|
const baseline = await readBaseline(baselinePath);
|
|
633
655
|
const startUrl = url ?? baseline.source.startUrl;
|
|
634
656
|
const options = crawlerOptions(values, baseline);
|
|
657
|
+
options.requestGate = createRequestGate(options.requestDelay);
|
|
635
658
|
if (options.sitemap) {
|
|
636
659
|
options.sitemap = mapUrlToTarget(
|
|
637
660
|
options.sitemap,
|
package/src/crawler.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { extractSeoData } from "./html.js";
|
|
3
|
+
import { createRequestGate } from "./request-gate.js";
|
|
3
4
|
import { isAllowedByRobots, parseRobots } from "./robots.js";
|
|
4
5
|
import { loadSitemapUrls } from "./sitemap.js";
|
|
5
6
|
import {
|
|
@@ -8,7 +9,7 @@ import {
|
|
|
8
9
|
normalizeUrl,
|
|
9
10
|
} from "./urls.js";
|
|
10
11
|
|
|
11
|
-
const DEFAULT_USER_AGENT = "seo-crawl-audit/0.1.
|
|
12
|
+
const DEFAULT_USER_AGENT = "seo-crawl-audit/0.1.2";
|
|
12
13
|
const MAX_HTML_BYTES = 5 * 1024 * 1024;
|
|
13
14
|
const MAX_ROBOTS_BYTES = 512 * 1024;
|
|
14
15
|
|
|
@@ -42,6 +43,7 @@ async function loadRobots(startUrl, options) {
|
|
|
42
43
|
const robotsUrl = new URL("/robots.txt", startUrl).href;
|
|
43
44
|
|
|
44
45
|
try {
|
|
46
|
+
await options.requestGate();
|
|
45
47
|
const response = await fetch(robotsUrl, {
|
|
46
48
|
headers: {
|
|
47
49
|
accept: "text/plain,*/*;q=0.1",
|
|
@@ -98,6 +100,7 @@ async function fetchPage(requestUrl, options) {
|
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
try {
|
|
103
|
+
await options.requestGate();
|
|
101
104
|
const response = await fetch(requestUrl, {
|
|
102
105
|
headers: {
|
|
103
106
|
accept: "text/html,application/xhtml+xml;q=0.9,*/*;q=0.1",
|
|
@@ -181,6 +184,8 @@ async function fetchPage(requestUrl, options) {
|
|
|
181
184
|
}
|
|
182
185
|
|
|
183
186
|
function withDefaults(options = {}) {
|
|
187
|
+
const requestDelay = options.requestDelay ?? 100;
|
|
188
|
+
|
|
184
189
|
return {
|
|
185
190
|
maxPages: options.maxPages ?? 100,
|
|
186
191
|
concurrency: options.concurrency ?? 5,
|
|
@@ -190,6 +195,9 @@ function withDefaults(options = {}) {
|
|
|
190
195
|
sitemap: options.sitemap ?? null,
|
|
191
196
|
sitemapData: options.sitemapData ?? null,
|
|
192
197
|
userAgent: options.userAgent ?? DEFAULT_USER_AGENT,
|
|
198
|
+
requestDelay,
|
|
199
|
+
requestGate:
|
|
200
|
+
options.requestGate ?? createRequestGate(requestDelay),
|
|
193
201
|
};
|
|
194
202
|
}
|
|
195
203
|
|
package/src/html-report.js
CHANGED
|
@@ -66,6 +66,7 @@ export function renderHtmlReport({
|
|
|
66
66
|
--shadow: 0 12px 36px rgba(31, 42, 68, 0.08);
|
|
67
67
|
}
|
|
68
68
|
* { box-sizing: border-box; }
|
|
69
|
+
[hidden] { display: none !important; }
|
|
69
70
|
body {
|
|
70
71
|
margin: 0;
|
|
71
72
|
background: var(--bg);
|
|
@@ -203,12 +204,15 @@ export function renderHtmlReport({
|
|
|
203
204
|
color: #344054;
|
|
204
205
|
font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
205
206
|
}
|
|
206
|
-
.empty {
|
|
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; }
|
|
207
210
|
footer { margin-top: 18px; color: var(--muted); text-align: center; }
|
|
208
211
|
@media (max-width: 900px) {
|
|
209
212
|
main { width: min(100% - 20px, 1440px); margin-top: 20px; }
|
|
210
213
|
header { display: block; }
|
|
211
214
|
.cards { grid-template-columns: repeat(2, 1fr); }
|
|
215
|
+
.card:last-child { grid-column: 1 / -1; }
|
|
212
216
|
.filters { grid-template-columns: 1fr; }
|
|
213
217
|
.results-bar { align-items: flex-start; flex-direction: column; }
|
|
214
218
|
}
|
|
@@ -222,7 +226,7 @@ export function renderHtmlReport({
|
|
|
222
226
|
<h1>${escapeHtml(reportTitle)}</h1>
|
|
223
227
|
<div class="meta">${escapeHtml(startUrl)}</div>
|
|
224
228
|
</div>
|
|
225
|
-
<div class="meta">Generated ${escapeHtml(generatedAt)}</div>
|
|
229
|
+
<div class="meta">Generated <time id="generated-at" datetime="${escapeHtml(generatedAt)}">${escapeHtml(generatedAt)}</time></div>
|
|
226
230
|
</header>
|
|
227
231
|
|
|
228
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>` : ""}
|
|
@@ -269,14 +273,14 @@ export function renderHtmlReport({
|
|
|
269
273
|
<span><strong>Info</strong> is useful context.</span>
|
|
270
274
|
</div>
|
|
271
275
|
<div class="results-bar">
|
|
272
|
-
<span id="result-count"></span>
|
|
273
|
-
<div class="pagination">
|
|
276
|
+
<span id="result-count" aria-live="polite"></span>
|
|
277
|
+
<div id="pagination" class="pagination">
|
|
274
278
|
<button id="previous" type="button">Previous</button>
|
|
275
|
-
<span id="page-info"></span>
|
|
279
|
+
<span id="page-info" aria-live="polite"></span>
|
|
276
280
|
<button id="next" type="button">Next</button>
|
|
277
281
|
</div>
|
|
278
282
|
</div>
|
|
279
|
-
<div class="table-wrap">
|
|
283
|
+
<div id="table-wrap" class="table-wrap">
|
|
280
284
|
<table>
|
|
281
285
|
<thead>
|
|
282
286
|
<tr>
|
|
@@ -290,25 +294,43 @@ export function renderHtmlReport({
|
|
|
290
294
|
<tbody id="issues"></tbody>
|
|
291
295
|
</table>
|
|
292
296
|
</div>
|
|
293
|
-
<div id="empty" class="empty"
|
|
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>
|
|
294
302
|
</section>
|
|
295
303
|
<footer>Generated locally. No report data was uploaded.</footer>
|
|
296
304
|
</main>
|
|
297
305
|
|
|
298
306
|
<script>
|
|
299
307
|
const report = ${data};
|
|
308
|
+
const generatedAt = document.querySelector("#generated-at");
|
|
300
309
|
const search = document.querySelector("#search");
|
|
301
310
|
const severity = document.querySelector("#severity");
|
|
302
311
|
const rule = document.querySelector("#rule");
|
|
303
312
|
const pageSize = document.querySelector("#page-size");
|
|
304
313
|
const tbody = document.querySelector("#issues");
|
|
305
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");
|
|
306
319
|
const resultCount = document.querySelector("#result-count");
|
|
320
|
+
const pagination = document.querySelector("#pagination");
|
|
307
321
|
const previous = document.querySelector("#previous");
|
|
308
322
|
const next = document.querySelector("#next");
|
|
309
323
|
const pageInfo = document.querySelector("#page-info");
|
|
310
324
|
let currentPage = 1;
|
|
311
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
|
+
|
|
312
334
|
const rules = [...new Set(report.issues.map((issue) => issue.rule))].sort();
|
|
313
335
|
for (const value of rules) {
|
|
314
336
|
const option = document.createElement("option");
|
|
@@ -397,11 +419,22 @@ export function renderHtmlReport({
|
|
|
397
419
|
tbody.append(row);
|
|
398
420
|
}
|
|
399
421
|
|
|
400
|
-
|
|
422
|
+
const issueLabel = report.issues.length === 1 ? "issue" : "issues";
|
|
423
|
+
resultCount.textContent = filtered.length.toLocaleString() + " of " + report.issues.length.toLocaleString() + " " + issueLabel;
|
|
401
424
|
pageInfo.textContent = "Page " + currentPage.toLocaleString() + " of " + pageCount.toLocaleString();
|
|
402
425
|
previous.disabled = currentPage === 1;
|
|
403
426
|
next.disabled = currentPage === pageCount;
|
|
404
|
-
|
|
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
|
+
}
|
|
405
438
|
}
|
|
406
439
|
|
|
407
440
|
for (const element of [search, severity, rule, pageSize]) {
|
|
@@ -420,6 +453,13 @@ export function renderHtmlReport({
|
|
|
420
453
|
currentPage += 1;
|
|
421
454
|
render();
|
|
422
455
|
});
|
|
456
|
+
clearFilters.addEventListener("click", () => {
|
|
457
|
+
search.value = "";
|
|
458
|
+
severity.value = "";
|
|
459
|
+
rule.value = "";
|
|
460
|
+
currentPage = 1;
|
|
461
|
+
render();
|
|
462
|
+
});
|
|
423
463
|
render();
|
|
424
464
|
</script>
|
|
425
465
|
</body>
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
}
|
package/src/sitemap.js
CHANGED
|
@@ -30,6 +30,7 @@ function extractLocations(xml) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
async function fetchSitemap(url, options) {
|
|
33
|
+
await options.requestGate?.();
|
|
33
34
|
const response = await fetch(url, {
|
|
34
35
|
headers: {
|
|
35
36
|
accept: "application/xml,text/xml,text/plain;q=0.9,*/*;q=0.1",
|
|
@@ -54,6 +55,7 @@ async function fetchSitemap(url, options) {
|
|
|
54
55
|
|
|
55
56
|
async function isSitemapResponse(url, options) {
|
|
56
57
|
try {
|
|
58
|
+
await options.requestGate?.();
|
|
57
59
|
const response = await fetch(url, {
|
|
58
60
|
method: "GET",
|
|
59
61
|
headers: {
|
|
@@ -91,6 +93,7 @@ export async function discoverSitemapUrl(inputUrl, options) {
|
|
|
91
93
|
const robotsUrl = new URL("/robots.txt", origin).href;
|
|
92
94
|
|
|
93
95
|
try {
|
|
96
|
+
await options.requestGate?.();
|
|
94
97
|
const response = await fetch(robotsUrl, {
|
|
95
98
|
headers: {
|
|
96
99
|
accept: "text/plain,*/*;q=0.1",
|