@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/LICENSE +21 -0
- package/README.md +792 -0
- package/action.yml +194 -0
- package/bin/seo-audit.mjs +483 -0
- package/package.json +52 -0
- package/src/agents.mjs +122 -0
- package/src/areas.mjs +135 -0
- package/src/audit.mjs +700 -0
- package/src/baseline.mjs +71 -0
- package/src/causes.mjs +167 -0
- package/src/checks.mjs +1253 -0
- package/src/compare.mjs +100 -0
- package/src/config.mjs +156 -0
- package/src/console.mjs +146 -0
- package/src/dupes.mjs +164 -0
- package/src/graph.mjs +89 -0
- package/src/http.mjs +228 -0
- package/src/options.mjs +77 -0
- package/src/parse.mjs +347 -0
- package/src/prompt.mjs +37 -0
- package/src/psi.mjs +200 -0
- package/src/redirects.mjs +145 -0
- package/src/report.mjs +868 -0
- package/src/robots.mjs +92 -0
- package/src/serve.mjs +81 -0
- package/src/site.mjs +714 -0
- package/src/sitemap.mjs +183 -0
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
|