@nurkamol/seo-audit 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,792 @@
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="docs/logo-dark.svg">
4
+ <img src="docs/logo.svg" alt="seo-audit — every page, not just the homepage" width="440">
5
+ </picture>
6
+ </p>
7
+
8
+ <p align="center">
9
+ <b>Crawl a site's sitemap and check every page</b> for the SEO, metadata and
10
+ structured-data problems that single-page graders miss.<br>
11
+ Zero dependencies · one command · works in CI.
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="https://github.com/marketplace/actions/full-site-seo-audit"><img src="https://img.shields.io/badge/GitHub%20Marketplace-Full--site%20SEO%20Audit-f97316?logo=github&logoColor=white" alt="GitHub Marketplace"></a>
16
+ <a href="https://github.com/nurkamol/seo-audit/actions/workflows/test.yml"><img src="https://github.com/nurkamol/seo-audit/actions/workflows/test.yml/badge.svg" alt="tests"></a>
17
+ <a href="https://github.com/nurkamol/seo-audit/releases"><img src="https://img.shields.io/github/v/release/nurkamol/seo-audit?color=f97316" alt="release"></a>
18
+ <img src="https://img.shields.io/badge/node-%E2%89%A518-3c873a" alt="node >= 18">
19
+ <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="zero dependencies">
20
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT"></a>
21
+ <a href="docs/hosting.md"><img src="https://img.shields.io/badge/Cloudflare%20Workers-optional%2C%20paid-f38020?logo=cloudflare&logoColor=white" alt="Optional hosted version on Cloudflare Workers"></a>
22
+ </p>
23
+
24
+ ```yaml
25
+ # In a workflow — https://github.com/marketplace/actions/full-site-seo-audit
26
+ - uses: nurkamol/seo-audit@v1
27
+ with:
28
+ url: https://example.com
29
+ ```
30
+
31
+ ```bash
32
+ # Or anywhere, with nothing installed
33
+ npx github:nurkamol/seo-audit@v1 https://example.com
34
+ ```
35
+
36
+ <p align="center"><img src="docs/terminal.svg" alt="Example output" width="820"></p>
37
+
38
+ > ### Not a terminal person? There is a window.
39
+ >
40
+ > **SEO Audit** for macOS is the same tool with a window instead of a prompt:
41
+ > type a domain, press return, watch it crawl, and read the report as cards you
42
+ > can expand, filter and search. No flags to learn, no output to redirect, and
43
+ > nothing leaves your machine.
44
+ >
45
+ > ```bash
46
+ > brew tap nurkamol/seo-audit https://github.com/nurkamol/seo-audit
47
+ > brew trust nurkamol/seo-audit
48
+ > brew install --cask seo-audit
49
+ > ```
50
+ >
51
+ > It is a window over this engine, never a second copy of it — the checks are
52
+ > the same ones the command line and the Action run, so the reports match. See
53
+ > [A window instead of a terminal](#a-window-instead-of-a-terminal), or
54
+ > [download it](https://github.com/nurkamol/seo-audit/releases/latest) if you
55
+ > would rather not use Homebrew.
56
+
57
+ ---
58
+
59
+ ## Why
60
+
61
+ Free SEO graders audit **one URL**, almost always the homepage. They will tell
62
+ you your title tag is 48 characters and your homepage loads in 0.38 seconds,
63
+ and happily award a B+ to a site where page 23 has a duplicate description,
64
+ page 31 is 112 words long, and the language switcher links to a 404.
65
+
66
+ That last one is real. This tool's first run against a production site found
67
+ the EN/RU switcher on every translated article pointing at a page that did not
68
+ exist. Three commercial graders had audited the same site that morning; none
69
+ could see it, because the link is only broken on pages they never opened.
70
+
71
+ **What it owns:** technical correctness, on every page, every deploy.
72
+ **What it will not pretend to own:** rankings. Those are decided by backlinks,
73
+ a Business Profile and content people want — none of which a crawler can fix.
74
+ It also refuses to *estimate* performance; with `--psi` it asks Google for the
75
+ real measurement instead.
76
+
77
+ | | seo-audit | Typical free grader |
78
+ |---|---|---|
79
+ | Pages checked | Every URL in the sitemap | One |
80
+ | Cross-page checks | Duplicates, hreflang pairs, orphans | — |
81
+ | Broken links | Whole-site sweep | — |
82
+ | Regression guard | Baseline diff, fails CI on new findings only | — |
83
+ | Performance | Google's own numbers via `--psi` | Estimated, or Lighthouse on one page |
84
+ | Output | Terminal · Markdown · HTML · JSON | A web page and a PDF upsell |
85
+ | Cost | Free, no account | Free tier, then a subscription |
86
+
87
+ <p align="center"><img src="docs/report.jpg" alt="The HTML report" width="820"></p>
88
+
89
+ ---
90
+
91
+ ## The playbook
92
+
93
+ The tool verifies about two thirds of what a site needs. The rest is judgement,
94
+ content and off-site work, and it is written down:
95
+
96
+ ### 📋 [docs/SEO-PLAYBOOK.md](docs/SEO-PLAYBOOK.md)
97
+
98
+ A checklist for taking a site from "probably fine" to genuinely clean —
99
+ crawlability, internal linking, multilingual, images, social previews,
100
+ structured data, performance, credibility and local search. Ordered by impact
101
+ and written from what was actually broken on real projects, including the traps
102
+ that only show up once you have hit them.
103
+
104
+ ```bash
105
+ # In a new project
106
+ curl -o docs/SEO-PLAYBOOK.md \
107
+ https://raw.githubusercontent.com/nurkamol/seo-audit/main/docs/SEO-PLAYBOOK.md
108
+ curl -o seo-audit.config.json \
109
+ https://raw.githubusercontent.com/nurkamol/seo-audit/main/docs/seo-audit.config.example.json
110
+
111
+ npx github:nurkamol/seo-audit@v1 https://example.com --html audit.html
112
+ ```
113
+
114
+ Then work down the checklist. It ends with a prompt for handing the whole
115
+ thing to an AI agent, including the two instructions that keep an enthusiastic
116
+ pass from doing damage: don't invent business facts, and don't declare success
117
+ against a stale cache.
118
+
119
+ ---
120
+
121
+ ## Usage
122
+
123
+ ```bash
124
+ # Crawl and print to the terminal
125
+ npx github:nurkamol/seo-audit https://example.com
126
+
127
+ # Save a Markdown report you can commit, diff, or send to a client
128
+ npx github:nurkamol/seo-audit https://example.com --md audit.md
129
+
130
+ # Audit a local build before it ships
131
+ npm run preview &
132
+ npx github:nurkamol/seo-audit http://localhost:4321 --limit 50
133
+
134
+ # A whole portfolio, one table
135
+ npx github:nurkamol/seo-audit one.example two.example three.example
136
+ ```
137
+
138
+ ### How the report is organised
139
+
140
+ Findings are grouped by **area**, not just by severity — Indexability, Content,
141
+ Links, Redirects, Images, Social, Structured data, Multilingual, Sitemap &
142
+ robots, Site & security, Performance. Severity says how loudly to complain; the
143
+ area says who fixes it. Thirty findings sorted only by severity is a list you
144
+ read once; the same thirty under *Images* and *Multilingual* is a list you can
145
+ hand to two people.
146
+
147
+ Findings on a page that **won't be indexed** — it carries `noindex`, or its
148
+ canonical points somewhere else — are marked `not indexable`. The same thin
149
+ page is a problem when Google will index it and noise when it won't, and that
150
+ distinction is often more useful than the severity.
151
+
152
+ ### What to fix first
153
+
154
+ Every report opens with the work rather than the findings. The same check on
155
+ pages of one section is one piece of work — that is how a generated site is
156
+ built — so a store's 2,081 findings read as 62 things to change, ordered worst
157
+ first and then by how much of the site points at them:
158
+
159
+ ```
160
+ ✗ No <h1> 10 pages under /pages/
161
+ ! Heading level jumps from h1 to h3 225 pages under /products/, 69% of the crawl
162
+ ! Structured data is missing headline 7 pages across the site, 1,047 links in
163
+ ```
164
+
165
+ The link counts and the distance from the homepage are counts of links that
166
+ were actually read, not a score: seven pages the site points at constantly are
167
+ worth more than ten it mentions once, and that is an ordering rather than a
168
+ number out of a hundred.
169
+
170
+ ### A window instead of a terminal
171
+
172
+ ```bash
173
+ node bin/seo-audit.mjs --serve # http://127.0.0.1:4321
174
+ ```
175
+
176
+ The same form the hosted version serves, on this machine: no account, no bill,
177
+ and none of a Worker's limits — the crawl is bounded by what this computer will
178
+ do, which is the only place a five-thousand-page site with `maxImageChecks`
179
+ past a thousand fits.
180
+
181
+ It is not a second implementation. `worker/index.mjs` is written against
182
+ `Request` and `Response`, which Node has too, so the same file answers both and
183
+ `src/serve.mjs` is thirty lines of adapter. Bound to the loopback address,
184
+ which is the whole of its security model.
185
+
186
+ There is a macOS app in [`mac/`](mac/README.md):
187
+
188
+ ```bash
189
+ brew tap nurkamol/seo-audit https://github.com/nurkamol/seo-audit
190
+ brew trust nurkamol/seo-audit # Homebrew asks before running a third party's code
191
+ brew install --cask seo-audit
192
+ # or build it yourself — swiftc and the command line tools, nothing else
193
+ ./mac/build.sh --run
194
+ ```
195
+
196
+ <p align="center"><img src="docs/shots/app.png" alt="The macOS app showing a report: 25 pages, 171 findings, 55 things to change" width="820"></p>
197
+
198
+ SwiftUI throughout, Liquid Glass, and the report drawn natively: cause cards
199
+ that expand into the pages they affect, filtering, search, and export as PDF,
200
+ HTML, Markdown, CSV or JSON. Every finished run is kept, so a seven-minute
201
+ crawl survives closing the window. No web view in it.
202
+
203
+ It is a window over this engine and not a second one. The app runs
204
+ `node bin/seo-audit.mjs --serve` as a child process and reads its stream, and
205
+ the grouping into causes travels with the findings rather than being recomputed
206
+ in Swift — a check written twice is a check that drifts. `Report.swift` keeps
207
+ that seam explicit, so a Swift engine would be one more conformance and no
208
+ change to anything above it.
209
+
210
+ ### In Raycast
211
+
212
+ ```
213
+ Preview a Site how big is this, and is it the right one — ~1s, 3 requests
214
+ Audit a Site crawl it and list what to change, worst first
215
+ Recent Reports runs the macOS app has already kept
216
+ ```
217
+
218
+ `raycast/` is a Raycast extension that imports the engine the same way the
219
+ Worker does — `import { preview } from "../../src/audit.mjs"` — so it
220
+ re-implements nothing and its reports match the terminal's. Raycast runs Node,
221
+ so unlike the hosted version the certificate checks work there.
222
+
223
+ **Preview is the command it exists for.** A crawl takes minutes and a launcher
224
+ is built for the second you spend in it, so the headline command is the engine's
225
+ `--dry-run`: how many URLs the sitemap lists, how many would be checked, and
226
+ where the weight of the site is. Auditing is capped by preference, and a
227
+ thousand-page site is told to use the app or the terminal rather than left
228
+ spinning.
229
+
230
+ ### What the pages actually do in Google
231
+
232
+ ```bash
233
+ node bin/seo-audit.mjs https://example.com --search-console
234
+ node bin/seo-audit.mjs https://example.com --search-console sc-domain:example.com
235
+ ```
236
+
237
+ Every other ordering here is derived from the site's own markup — how many
238
+ links point at a page, how far it is from the homepage. Those are proxies.
239
+ Impressions are not: a broken canonical on a page with four thousand
240
+ impressions a month is a different sentence from the same canonical on a page
241
+ nobody has been shown.
242
+
243
+ Opt-in, and the only thing in this tool that needs an account. It reads
244
+ `GSC_CLIENT_ID`, `GSC_CLIENT_SECRET` and `GSC_REFRESH_TOKEN` from the
245
+ environment or from `~/.config/seo-audit/.env`, deliberately outside any
246
+ repository. A domain property is named `sc-domain:example.com` rather than by
247
+ its URL. Missing credentials, or a property the account cannot read, are a note
248
+ and the rest of the audit is unaffected.
249
+
250
+ ### Telling two readers apart
251
+
252
+ ```bash
253
+ node bin/seo-audit.mjs https://example.com --compare-as googlebot
254
+ ```
255
+
256
+ Fetches a sample of pages a second time as somebody else and reports what
257
+ changed. Not a byte comparison — a nonce, a timestamp and a cart count all
258
+ differ between two fetches of the same page by the same client. What is
259
+ compared is what a search engine reads: status, title, canonical, robots meta,
260
+ word count and link count.
261
+
262
+ forbes.com serves Googlebot roughly half the words it serves Chrome on every
263
+ page sampled. jekyllrb.com is identical on all ten, and says so rather than
264
+ staying silent.
265
+
266
+ ### Crawling as something else
267
+
268
+ ```bash
269
+ # What Google is served, which is not always what a person gets
270
+ node bin/seo-audit.mjs https://example.com --browser googlebot
271
+
272
+ # What a host that blocks crawlers will answer at all
273
+ node bin/seo-audit.mjs https://example.com --browser chrome --os windows
274
+ ```
275
+
276
+ Three reasons this matters, and none of them is dressing up. A site that
277
+ answers a browser and blocks everything else is common, and the report from a
278
+ blocked crawl is a report about the block. Some sites serve different HTML to a
279
+ crawler than to a person, and fetching as Googlebot is the only way to see it.
280
+ And Google indexes what its **smartphone** crawler sees — `--browser googlebot`
281
+ is that one, `googlebot-desktop` is the other.
282
+
283
+ `--os` says which system the browser is running on and defaults to yours.
284
+ A combination that does not exist is refused rather than approximated:
285
+
286
+ ```
287
+ $ … --browser safari --os windows
288
+ safari does not run on windows. It runs on: macos, ios.
289
+ ```
290
+
291
+ The strings are a snapshot and will age — browser versions move every few weeks
292
+ and nothing here can know that. They are close enough for a server deciding
293
+ whether to answer, and `--user-agent` still takes a literal string for anything
294
+ that has to be exact. It outranks `--browser` when both are given.
295
+
296
+ ### Checking outbound links
297
+
298
+ Off by default, and that's a judgement rather than an omission:
299
+
300
+ ```bash
301
+ npx github:nurkamol/seo-audit https://example.com --check-external
302
+ ```
303
+
304
+ These are other people's servers. They rate-limit, they bot-block, and plenty
305
+ answer `403` to anything without a browser's fingerprint — so only a **404, a
306
+ 410, or no answer at all** is ever reported as broken. An outbound link that
307
+ merely redirects is a note, not a problem. `maxExternalChecks` bounds the sweep
308
+ (default 100), because one machine hammering a hundred third parties is rude at
309
+ scale.
310
+
311
+ ### Watching a run
312
+
313
+ A crawl of any size is otherwise silent from the first line to the last, which
314
+ makes a slow site look exactly like a hung one. `--verbose` prints each request
315
+ as it happens:
316
+
317
+ ```
318
+ sitemap /sitemap-index.xml 42 URLs
319
+ crawl 200 128ms /
320
+ crawl 200 180ms /faq/
321
+ crawl 200 343ms /about/
322
+ crawl 42 pages in 6.8s
323
+ links 87 distinct targets to check
324
+ links 404 92ms /old-page/
325
+ images 26 distinct images to check
326
+ psi measuring 1 of 3 (~12s) /
327
+ ```
328
+
329
+ Plain lines rather than a spinner, deliberately: a long run is exactly the one
330
+ whose output gets piped to a file or read back out of a CI log, and neither can
331
+ show a cursor trick. It also means the page a crawl is stuck on stays on screen
332
+ instead of being overwritten — a timeout arrives as status `0`, so a stall is
333
+ visible rather than blank.
334
+
335
+ Everything goes to **stderr**, so `--json` and `--md` are unaffected and
336
+ `… --verbose --json report.json` still writes clean JSON. `--quiet` wins over
337
+ `--verbose`: asking for silence and getting a running commentary would be the
338
+ more surprising of the two.
339
+
340
+ ### Checking a migration's redirects
341
+
342
+ A redirect map is written once, verified once, and then rots quietly: a later
343
+ change to a destination turns an entry into a hop through a 404, and nothing
344
+ tells anyone. The old URLs are the ones carrying the links and the rankings, so
345
+ this is one of the few SEO failures that is both expensive and completely
346
+ silent.
347
+
348
+ ```bash
349
+ npx github:nurkamol/seo-audit https://example.com --redirects _redirects
350
+ ```
351
+
352
+ The file is the Netlify `_redirects` shape, which is also what most people
353
+ write by hand — `#` comments, and `to` and the status both optional:
354
+
355
+ ```
356
+ /old-path /new-path 301
357
+ /also-old /new-path
358
+ /just-an-old-url
359
+ ```
360
+
361
+ Every old URL is asked for, and what actually happens is reported:
362
+
363
+ | | |
364
+ |---|---|
365
+ | `redirect-dead` | error — the old URL 404s. The rule never shipped |
366
+ | `redirect-broken` | error — it redirects, and lands on nothing. Worse than no rule, because it looks handled |
367
+ | `redirect-not-applied` | warning — the old URL still answers 200 |
368
+ | `redirect-hops` | warning — more than one hop to arrive |
369
+ | `redirect-elsewhere` | warning — lands somewhere the map does not expect |
370
+ | `redirect-temporary` | warning — served as 302 where the map says 301 |
371
+
372
+ Rules with a `*` or a `:placeholder` match a shape rather than a URL, so asking
373
+ for them literally proves nothing. They are counted and reported, never guessed
374
+ at. A rule that works in one hop reports nothing at all.
375
+
376
+ ### Sites without a sitemap
377
+
378
+ If no sitemap can be found, the crawl follows links from the homepage instead
379
+ of stopping — the sites least likely to have been looked after were the ones
380
+ this used to refuse to look at. `no-sitemap` is still reported, as a warning
381
+ rather than an error, because the pages get audited either way.
382
+
383
+ The link crawl obeys `robots.txt`, follows a redirecting homepage (plenty of
384
+ sites send `/` to a locale), skips assets, and treats two URLs redirecting to
385
+ one page as one page. `missing-from-sitemap` stays quiet, since every page
386
+ found this way is by definition absent from a sitemap that does not exist.
387
+
388
+ ### A portfolio
389
+
390
+ Name more than one site and the report becomes a table, worst site first —
391
+ which is the question a per-site report can never answer, because each one
392
+ only ever sees itself.
393
+
394
+ ```
395
+ Portfolio — 3 sites · 24 pages · 36.3s
396
+
397
+ SITE PAGES ✗ ! ·
398
+ fitculturepilates.com 8 2 28 20
399
+ vitejs.dev 8 2 19 32
400
+ astro.build 8 1 31 26
401
+
402
+ 5 errors across 3 of 3 sites · 78 warnings · 78 notes
403
+ ```
404
+
405
+ `--md` and `--html` write one file with the table on top and each site's full
406
+ report underneath, so a single section can be lifted out and sent to whoever
407
+ owns that site. `--json` writes one object with a `sites` array. The run exits
408
+ 1 if **any** site fails, because a portfolio check that passes while a site in
409
+ it is broken is a check nobody can trust.
410
+
411
+ Sites run one at a time: interleaved progress from twenty hosts is unreadable,
412
+ and each audit is already parallel inside itself.
413
+
414
+ `--baseline`, `--against` and `--update-baseline` compare a site against
415
+ itself, so they refuse to run across a portfolio rather than half-answering the
416
+ question. Run those per site. The GitHub Action is single-site for the same
417
+ reason.
418
+
419
+ ### Options
420
+
421
+ | Option | Default | |
422
+ |---|---|---|
423
+ | `--md <file>` | — | Write a Markdown report |
424
+ | `--html <file>` | — | Write a self-contained HTML report — one file, no assets |
425
+ | `--since <date>` | — | Crawl only URLs the sitemap says changed on or after this date. Refuses when `lastmod` cannot answer it |
426
+ | `--exclude <glob>` | — | Leave URLs out of the crawl. Repeatable; `*` stops at a slash, `**` does not |
427
+ | `--dry-run` | — | Say what would be crawled and stop. A handful of requests instead of hundreds |
428
+ | `--write-sitemap <file>` | — | Write the sitemap this site should have had. Refuses on a crawl that did not see the whole site |
429
+ | `--json <file>` | — | Write a JSON report — findings, the grouped `causes` with their scope lines, and `meta`. Also usable as a baseline, which carries the findings only |
430
+ | `--csv <file>` | — | Write the findings as a spreadsheet, one row each |
431
+ | `--baseline <file>` | — | Compare against a previous `--json` run; show only what changed |
432
+ | `--update-baseline` | — | Rewrite the baseline after comparing |
433
+ | `--limit <n>` | 200 | Maximum pages to check |
434
+ | `--concurrency <n>` | 6 | Parallel requests. Comes down on its own if the server answers `429` |
435
+ | `--sitemap <url>` | auto | If `robots.txt` doesn't declare one and it isn't at a usual path. Without any sitemap, the crawl follows links instead |
436
+ | `--redirects <file>` | — | Check a migration's redirect map against the live site (see below) |
437
+ | `--check-external` | — | Also check links pointing off the site (see below) |
438
+ | `--browser <name>` | — | Crawl as `chrome`, `firefox`, `safari`, `edge`, `googlebot`, `googlebot-desktop` or `bingbot` |
439
+ | `--os <name>` | this one | The system that browser runs on: `macos`, `windows`, `linux`, `android`, `ios` |
440
+ | `--user-agent <ua>` | `seo-audit …` | Identify as something else. A literal string, and it outranks `--browser` |
441
+ | `--config <file>` | `seo-audit.config.json` | Per-site configuration |
442
+ | `--ignore <ids>` | — | Comma-separated check ids to silence for this run |
443
+ | `--psi <urls>` | — | Measure these pages with PageSpeed Insights. A path glob names a section (see below) |
444
+ | `--psi-sample <n>` | 3 | Pages measured per section glob |
445
+ | `--psi-strategy` | `mobile` | `mobile` or `desktop` |
446
+ | `--against <url>` | — | Compare against another deployment now — preview vs production |
447
+ | `--settle <s>` | — | Wait until the site serves consistent HTML before crawling |
448
+ | `--fail-on <level>` | `error` | Exit 1 at `error`, `warn`, `new`, or `never` |
449
+ | `--version` | — | Print the version |
450
+ | `--quiet` | — | Print nothing; use the exit code and the files |
451
+ | `--verbose` | — | Print each request as it happens, to stderr (see below) |
452
+
453
+ ---
454
+
455
+ ## Configuration
456
+
457
+ Every site has findings that are true and deliberate. A contact page is *meant*
458
+ to be short; a privacy policy has no business carrying editorial links. Left
459
+ unsaid, those fill the report with noise nobody reads, and the one new finding
460
+ that matters gets lost.
461
+
462
+ Drop a `seo-audit.config.json` next to where you run it:
463
+
464
+ ```json
465
+ {
466
+ "limit": 200,
467
+ "failOn": "error",
468
+ "limits": { "thinWords": 250 },
469
+ "sites": [
470
+ "https://one.example",
471
+ { "url": "https://two.example", "limit": 50, "ignore": ["thin-content"] }
472
+ ],
473
+ "maxLinkChecks": 200,
474
+ "maxImageChecks": 200,
475
+ "psi": ["/", "/pricing/", "/journal/**"],
476
+ "ignore": [
477
+ "img-srcset",
478
+ { "id": "thin-content", "urls": ["/contact/", "/thanks/", "**/legal/**"] },
479
+ { "id": "no-editorial-links", "urls": ["**/privacy-policy/", "**/terms-of-use/"] }
480
+ ],
481
+ "expect": [
482
+ { "urls": ["/journal/*/"], "types": ["BlogPosting"] },
483
+ { "urls": ["/"], "types": ["LocalBusiness", "WebSite"] },
484
+ { "urls": ["/faq/"], "types": ["FAQPage"] }
485
+ ]
486
+ }
487
+ ```
488
+
489
+ - **`sites`** — a portfolio. Each entry is a URL, or an object with a `url` and
490
+ whatever that site overrides — a portfolio is not a list of interchangeable
491
+ sites, and one of them has a deliberately short contact page. Overrides land
492
+ on top of the shared config; `ignore` accumulates rather than replacing,
493
+ since a portfolio-wide rule and a site rule are both meant to apply. URLs
494
+ given on the command line replace this list entirely, which is how you audit
495
+ a subset.
496
+ - **`limits`** — thresholds this site disagrees with: `titleMin`, `titleMax`,
497
+ `descMin`, `descMax`, `thinWords`, `slowMs`, `maxClickDepth`.
498
+ - **`maxLinkChecks`** — how many distinct link targets the site-wide sweep
499
+ fetches, default 200. The sweep checks every internal link on every crawled
500
+ page, so a large site can present thousands of targets; this bounds the run.
501
+ When it bites, the report says how many were left unchecked rather than
502
+ quietly describing a fraction of the site.
503
+ - **`maxImageChecks`** — the same bound for the image sweep, default 200,
504
+ counted in distinct **files** rather than URLs. An image CDN serves one file
505
+ at every size asked for, so `photo.avif?width=150` and `?width=750` are one
506
+ image and one request; `width`, `height`, `w`, `h` and `dpr` are dropped
507
+ before counting, and `v` is not, because a different version is a different
508
+ asset. On a real store this was 488 files behind 767 URLs. The report names
509
+ the number to set if the cap was reached.
510
+
511
+ - **`maxExternalChecks`** — how many outbound links `--check-external` fetches,
512
+ default 100. Third-party hosts are somebody else's to hammer.
513
+ - **`redirects`** — path to a redirect map, the same as `--redirects`.
514
+ **`maxRedirectChecks`** bounds how many of its rules are tested, default 200.
515
+ - **`psi`** — pages to measure with PageSpeed Insights, as paths. A path glob
516
+ names a section: `/journal/**` measures a sample of the crawled pages under
517
+ it, three by default, spread across the section rather than taken off the
518
+ front. PageSpeed Insights costs about 12 seconds a page, so a section of
519
+ forty measured whole is eight minutes — the report says how many of the
520
+ matched pages were actually measured, because a sample that stayed quiet
521
+ about the rest would read as a clean bill of health for the whole section.
522
+ Raise it with `--psi-sample`, and expect the wait. The sample is the same on
523
+ every run, so a `--baseline` comparison stays meaningful.
524
+ - **`ignore`** — a bare check id silences it everywhere; `{ id, urls }` silences
525
+ it only where it is intended. `*` stops at a slash, `**` does not. The id is
526
+ printed with every finding.
527
+ - **`expect`** — which schema types a group of pages must carry. This is the
528
+ difference between "the JSON-LD parses" and "this article is actually marked
529
+ up as an article", and it is the check that catches a template quietly
530
+ dropping its structured data.
531
+
532
+ ---
533
+
534
+ ## Catching regressions
535
+
536
+ The useful question after the first run is not "how many warnings" — that
537
+ number stops moving. It is "did this deploy break something that worked
538
+ yesterday".
539
+
540
+ ```bash
541
+ # First run writes the baseline
542
+ seo-audit https://example.com --baseline seo-baseline.json
543
+
544
+ # Later runs report only the difference
545
+ seo-audit https://example.com --baseline seo-baseline.json
546
+ ```
547
+
548
+ ```
549
+ ✓ 3 fixed since 2026-08-08
550
+ · Link to a page that does not exist https://example.com/ru/journal/…
551
+
552
+ ✗ 1 new since 2026-08-08
553
+ ✗ Missing expected structured data: BlogPosting
554
+ Page declares WebSite.
555
+ · https://example.com/journal/new-article/
556
+
557
+ 12 unchanged
558
+ ```
559
+
560
+ Commit the baseline. `--fail-on new` then fails a build on a regression while
561
+ tolerating the backlog you already know about — which is what makes the check
562
+ survivable in CI instead of being switched off in week two.
563
+
564
+ ### In CI
565
+
566
+ ```yaml
567
+ - run: |
568
+ npx github:nurkamol/seo-audit https://example.com \
569
+ --baseline seo-baseline.json --fail-on new --md audit.md
570
+ - uses: actions/upload-artifact@v4
571
+ if: always()
572
+ with: { name: seo-audit, path: audit.md }
573
+ ```
574
+
575
+ ---
576
+
577
+ ## Hosting it, for people who will not open a terminal
578
+
579
+ Optional, and off the main path. Everything above is free and runs on your own
580
+ machine; this is a small password-protected web page you deploy to **your own
581
+ Cloudflare account**, so a colleague can audit a site by filling in a form. It
582
+ runs the same code and produces the same report.
583
+
584
+ <p align="center">
585
+ <a href="https://deploy.workers.cloudflare.com/?url=https://github.com/nurkamol/seo-audit"><img src="https://deploy.workers.cloudflare.com/button" alt="Deploy to Cloudflare" width="184" height="39"></a>
586
+ </p>
587
+
588
+ <p align="center">
589
+ <sub><b>Needs the $5/month Workers Paid plan.</b> Your account, your bill.<br>
590
+ Read <a href="docs/hosting.md">docs/hosting.md</a> before you click it.</sub>
591
+ </p>
592
+
593
+ The short version:
594
+
595
+ - **It cannot run on Cloudflare's free plan.** 10ms of CPU and 50 outbound
596
+ fetches per invocation works out at about sixteen pages — which is the exact
597
+ failure this tool exists to point at. It needs the **$5/month Workers Paid**
598
+ plan.
599
+ - **After that it is effectively free to run.** Cloudflare does not bill for the
600
+ fetches a Worker makes, so the crawl costs nothing and an audit is about a
601
+ hundredth of a cent of CPU. The $5 is the whole bill for normal use.
602
+ - **The charge is recurring and it is yours.** Your account, your card, your
603
+ agreement with Cloudflare. Deleting the Worker does not cancel the plan.
604
+ MIT licence, no warranty, at your own risk.
605
+ - **It will not audit anything until you set `AUDIT_TOKEN`.** What you are
606
+ deploying is a crawler with a public address, and an open one gets pointed at
607
+ other people's sites from your account. Set `ALLOWED_HOSTS` too.
608
+ - **Two checks do not work there.** `tls-expiring` and `tls-expired` need a TLS
609
+ socket the Workers runtime does not offer. Every hosted report says so, rather
610
+ than quietly coming up two checks short.
611
+
612
+ If you have a GitHub repository, the Action above is free, unlimited and
613
+ better. This is for the case where it genuinely has to be a web page.
614
+
615
+ ---
616
+
617
+ ## What it checks
618
+
619
+ Findings come at three levels: **error** (wrong, and costing traffic), **warning** (worth fixing, judgement involved), **note** (worth knowing, may be deliberate).
620
+
621
+ ### Per page
622
+
623
+ | Check | Level |
624
+ |---|---|
625
+ | Page returns 200 and is not a redirect listed in the sitemap | error |
626
+ | A page the server answers `429` to is reported as rate limited, never as a page that failed — the crawl waits, slows down and comes back first | note |
627
+ | `noindex` on a page the sitemap advertises | error |
628
+ | `X-Robots-Tag: noindex` — the same instruction as a header, invisible in the HTML | error |
629
+ | `nofollow` on the page — Google follows none of its links, navigation included | warning |
630
+ | The robots meta tag and `X-Robots-Tag` don't contradict each other | warning |
631
+ | No `<meta http-equiv="refresh">` — a redirect nothing treats as one | warning |
632
+ | Internal links aren't `rel="nofollow"` — a page refusing to pass through its own site | note |
633
+ | `<title>` present, 15–60 characters | error / warning |
634
+ | Meta description present, 70–160 characters | warning |
635
+ | Exactly one `<h1>` | error / warning |
636
+ | `lang` attribute and viewport meta | warning / error |
637
+ | The viewport doesn't block zooming — `user-scalable=no` or a `maximum-scale` under 2 forbids the 200% WCAG 1.4.4 asks for, and Safari has ignored it since iOS 10 | warning |
638
+ | The viewport isn't a fixed pixel width — `width=1024` lays the page out that wide on a phone and scales it down, and that is what Google indexes | warning |
639
+ | Canonical present, single, self-referencing | warning / error / note |
640
+ | The canonical target isn't `noindex` — a page that hands its indexing to one leaves the index with it | error |
641
+ | Page 2 of an archive names itself, not page 1 — Google's guidance is "Don't use the first page of a paginated sequence as the canonical page", and a sitemap almost never lists these pages, so they're read where they're linked | error |
642
+ | The canonical target isn't itself canonicalised elsewhere — Google needn't follow a chain | warning |
643
+ | `og:title`, `og:description`, `og:image` present | warning |
644
+ | `og:image` is an absolute URL — a scraper has no page to resolve a relative one against | error |
645
+ | `og:image` is not WebP — LinkedIn won't render it, WhatsApp is unreliable | warning |
646
+ | `og:image` declares width and height | note |
647
+ | `hreflang` codes are well formed — `en_US` with an underscore is the usual slip | error |
648
+ | `hreflang` lists the page itself, not only its translations | warning |
649
+ | `<html lang>` agrees with what the page's own `hreflang` calls it | warning |
650
+ | `<html lang>` agrees with the `Content-Language` header, compared by primary subtag — a header listing several languages agrees if the page's is one of them | warning |
651
+ | JSON-LD parses and carries a `@type` (or a `@graph`) | error / warning |
652
+ | Types Google can render carry the properties it requires — an `Article` with no `headline` gets no rich result | warning |
653
+ | The dates do not contradict themselves — a page modified before it was published, or dated next Tuesday, is not a freshness signal | warning |
654
+ | Images named in structured data actually load | warning |
655
+ | Every `<img>` has an `alt` attribute (empty is correct for decorative) | error |
656
+ | `alt` isn't a filename — `alt="DSC_0042.jpg"` is what a CMS fills in for you | warning |
657
+ | `alt` isn't a placeholder — `alt="image"`, `alt="logo"` name the medium, not the content | warning |
658
+ | Three or more images don't share one `alt` | note |
659
+ | `alt` is under 125 characters — it's read in one breath, with no way to skim | note |
660
+ | `title` doesn't just repeat `alt` — one field filling both adds nothing and can be read twice | note |
661
+ | `title` isn't attached to an image declared decorative — the markup contradicts itself | note |
662
+ | Every `<img>` has `width` and `height` — otherwise the page reflows | warning |
663
+ | Images offer a `srcset` rather than one size for every screen | note |
664
+ | No image is both `loading="lazy"` and `fetchpriority="high"` — told to wait and told to hurry, and lazy decides when | note |
665
+ | Word count above ~300 — Japanese, Chinese and Thai are counted by character, since they don't space words | warning |
666
+ | At least one link inside the content, not just navigation | note |
667
+ | No `http://` **subresources** on an HTTPS page — a hyperlink to one is not mixed content | error |
668
+ | HTML arrives compressed, once it's big enough to be worth compressing | warning |
669
+ | Images declared decorative by `alt=""` **or** `role="presentation"` are left alone | — |
670
+
671
+ ### Across pages
672
+
673
+ | Check | Level |
674
+ |---|---|
675
+ | No two pages share a title | warning |
676
+ | No two pages share a meta description | warning |
677
+ | `hreflang` is reciprocal — Google drops one-way pairs | error |
678
+ | Something in the `hreflang` set is an `x-default` | note |
679
+ | Pages carry the schema types `expect` says they should | error |
680
+ | No page is an orphan — in the sitemap but linked from nowhere | warning |
681
+ | Orphans are only looked for when the crawl actually saw the site — a run cut short by `--limit`, or with pages that never loaded, says so instead of calling a fragment full of orphans | note |
682
+ | Every page is within four clicks of the homepage, counted over the links actually in the HTML | note |
683
+ | Every page has *some* path from the homepage — one that hangs off an unreachable page is only found by handing Google the sitemap | warning |
684
+ | Every destination has at least one link that names it — an icon or an `alt=""` thumbnail with no text, no `aria-label` and no `title` tells Google nothing and reads a URL aloud to a screen reader | warning |
685
+ | No page is described only by "read more" — the words on a link are the one description of a page that does not come from the page itself | note |
686
+ | No phrase describes two different pages — "Collections" pointing at both the reference page and the tutorial chapter makes them compete, and only destinations this crawl fetched are compared | note |
687
+
688
+ ### Whole site
689
+
690
+ | Check | Level |
691
+ |---|---|
692
+ | `robots.txt` exists, does not block everything, advertises the sitemap | error / warning / note |
693
+ | No sitemap URL is disallowed by `robots.txt` — the site contradicting itself | error |
694
+ | Every sitemap URL is actually indexable — not `noindex`, not canonicalised away | warning |
695
+ | Each sitemap file is within the protocol's 50,000 URLs and 50MB | error |
696
+ | **Pages that are the same page again** — the bodies compared, not just titles and descriptions. Silent on a page that says `noindex`, on a page whose `rel=canonical` already points at the original, and on a page with no `<main>` or `<article>` to read content from, which it says rather than skips | warning |
697
+ | A sitemap is actually absent before absence is reported — a probe that answers 429 is `sitemap-not-checked`, not `no-sitemap` | warning |
698
+ | The sitemap declares `lastmod` at all | note |
699
+ | `lastmod` differs between pages — one date on every URL is a build stamp, and crawlers learn to ignore it | note |
700
+ | No URL is listed twice — across two files of an index, or twice in one. Image and video sitemaps are skipped, since one entry per image is the format working | note |
701
+ | No `lastmod` is in the future | warning |
702
+ | A favicon Google can use — the home page declares one that loads, or `/favicon.ico` is there | warning / note |
703
+ | `llms.txt` exists | note |
704
+ | Everything once-per-domain is read on the host that answers — audit `example.com` when the site lives at `www.` and the audit moves there, saying so, rather than reading robots.txt off a 301 | note |
705
+ | `http://`, `www.` and `https://www.` each reach the canonical host in one hop — a variant that answers 429 is reported as **not checked**, never as dead | warning / note |
706
+ | The TLS certificate is not expired, and not expiring within 14 days | error / warning |
707
+ | HSTS, `X-Content-Type-Options`, `Referrer-Policy`, CSP headers | warning / note |
708
+ | A URL that cannot exist returns 404, not a 200 error page — the redirect chain is followed to its end | error / warning |
709
+ | There is something to audit at all — no sitemap *and* no crawlable homepage is `nothing-crawlable`. If the server rate-limited the run instead, it says so and claims nothing about the site | error |
710
+ | Every internal link resolves — the site-wide 404 sweep | error |
711
+ | Any linked page that turns out to be page 2 of a sequence gets its canonical read, from the response the sweep already fetched | error |
712
+ | Outbound links resolve, with `--check-external` — only a 404, 410 or no answer counts | warning / note |
713
+ | Every `<img>` actually loads — 403 is hotlink protection, not a broken file, and is not reported | error |
714
+ | Every `hreflang` alternate actually loads, including versions outside the crawl | error |
715
+ | Internal links point at final URLs rather than redirects | note |
716
+ | No page is linked but missing from the sitemap | warning |
717
+ | The link and image sweeps say so when they stop at their cap rather than implying they checked everything | note |
718
+ | Every `og:image` actually loads, and isn't too heavy to scrape | error / warning |
719
+
720
+ ---
721
+
722
+ ## Reading the output
723
+
724
+ ```
725
+ https://example.com
726
+ 31 pages · 71 requests · 12.9s
727
+
728
+ ✗ No <h1> ×17
729
+ The page has no headline.
730
+ · https://example.com/schedule/
731
+ · https://example.com/teacher-trainings/
732
+
733
+
734
+ ✗ Sitemap URL redirects
735
+ 301 → https://example.com/summer-offer/. A sitemap should list final URLs only.
736
+ · https://example.com/spring-into-summer/
737
+
738
+ ! og:image is WebP ×17
739
+ LinkedIn does not render WebP previews and WhatsApp is unreliable with it.
740
+ · https://example.com/reformer-classes/
741
+
742
+
743
+ 20 error 91 warning 12 note
744
+ ```
745
+
746
+ Findings are grouped by check, not by page, because the fix is usually one change applied everywhere.
747
+
748
+ Some warnings are meant to be lived with. A contact page is *supposed* to be short; a privacy policy has no business carrying editorial links. The tool reports what is true and leaves the judgement to you — it has no way to know which pages are meant to rank.
749
+
750
+ ---
751
+
752
+ ## Use with
753
+
754
+ | Tool | For |
755
+ |---|---|
756
+ | [PageSpeed Insights](https://pagespeed.web.dev) | Core Web Vitals, render-blocking resources, image sizing in practice |
757
+ | [WebPageTest](https://webpagetest.org) | The same, from a location your customers actually live in |
758
+ | [Rich Results Test](https://search.google.com/test/rich-results) | Whether Google parses your structured data, not just whether it is valid |
759
+ | [Search Console](https://search.google.com/search-console) | What Google has actually indexed. The only opinion that counts |
760
+ | [Ahrefs Webmaster Tools](https://ahrefs.com/webmaster-tools) | Backlinks — free for domains you verify |
761
+
762
+ ---
763
+
764
+ ## Versioning
765
+
766
+ Releases follow [semver](https://semver.org). Three ways to pin, in order of
767
+ how much you value stability over freshness:
768
+
769
+ | Reference | Gets you |
770
+ |---|---|
771
+ | `@v1` | The latest release that is backwards compatible. Moves forward with each one. Recommended |
772
+ | `@v1.22.0` | Exactly that release, forever |
773
+ | `@main` | Whatever was last pushed, including work in progress |
774
+
775
+ The same applies to `npx github:nurkamol/seo-audit#v1`.
776
+
777
+ ---
778
+
779
+ ## Contributing
780
+
781
+ Adding a check means one entry in `src/checks.mjs` (per page), `src/checks.mjs → crossPageChecks` (needs every page), or `src/site.mjs` (once per domain). Each returns `{ level, id, title, detail, url }` and that is the whole contract.
782
+
783
+ Two rules that keep the tool trustworthy:
784
+
785
+ 1. **No false positives.** A check that cries wolf gets the whole report ignored. If a pattern is sometimes legitimate, it is a `note`, not an `error`.
786
+ 2. **No dependencies.** It must keep running with a bare `npx` on a machine with nothing installed.
787
+
788
+ See [ROADMAP.md](ROADMAP.md) for what is planned, [CHANGELOG.md](CHANGELOG.md) for what changed.
789
+
790
+ ## Licence
791
+
792
+ MIT