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 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, sitemap and robots metadata, and
262
- the extracted SEO state of every scanned page. It is deterministic enough to
263
- review and commit when a team wants versioned regression protection.
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
- limits concurrency to five requests, and applies a ten-second timeout. Increase
324
- concurrency carefully and only on sites you are allowed to test.
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 { display: none; padding: 48px 20px; color: var(--muted); text-align: center; }
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-07-29T22:47:54.589Z</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>
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">No issues match the selected filters.</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>
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
- resultCount.textContent = filtered.length.toLocaleString() + " of " + report.issues.length.toLocaleString() + " issue(s)";
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
- empty.style.display = filtered.length === 0 ? "block" : "none";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-crawl-audit",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Local-first CLI for SEO audits, resumable crawls, HTML reports, and regression checks.",
5
5
  "type": "module",
6
6
  "bin": {
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.0";
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.0",
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.0";
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
 
@@ -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 { display: none; padding: 48px 20px; color: var(--muted); text-align: center; }
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">No issues match the selected filters.</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>
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
- resultCount.textContent = filtered.length.toLocaleString() + " of " + report.issues.length.toLocaleString() + " issue(s)";
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
- empty.style.display = filtered.length === 0 ? "block" : "none";
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",