@nurkamol/seo-audit 1.33.1 → 1.35.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 +146 -3
- package/action.yml +23 -0
- package/bin/seo-audit.mjs +53 -9
- package/package.json +5 -1
- package/src/agents-ai.mjs +110 -0
- package/src/areas.mjs +12 -1
- package/src/audit.mjs +59 -0
- package/src/baseline.mjs +61 -9
- package/src/causes.mjs +13 -1
- package/src/checks.mjs +2 -2
- package/src/console.mjs +162 -11
- package/src/exports.mjs +84 -0
- package/src/library.mjs +156 -0
- package/src/llms.mjs +162 -0
- package/src/open-url.mjs +49 -0
- package/src/options.mjs +58 -12
- package/src/parse.mjs +41 -3
- package/src/report.mjs +528 -73
- package/src/schema.mjs +324 -0
- package/src/score.mjs +410 -0
- package/src/serve.mjs +11 -0
- package/src/site.mjs +36 -1
package/README.md
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
<p align="center">
|
|
15
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
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/latest"><img src="https://img.shields.io/badge/Download-macOS%20app-0a0a0a?logo=apple&logoColor=white" alt="Download the macOS app"></a>
|
|
18
|
+
<a href="https://www.npmjs.com/package/@nurkamol/seo-audit"><img src="https://img.shields.io/npm/v/@nurkamol/seo-audit?color=cb3837&logo=npm&logoColor=white&label=npm" alt="npm"></a>
|
|
17
19
|
<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
20
|
<img src="https://img.shields.io/badge/node-%E2%89%A518-3c873a" alt="node >= 18">
|
|
19
21
|
<img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="zero dependencies">
|
|
@@ -279,6 +281,23 @@ Interactive by nature, so it is deliberately **not** a GitHub Action input: a
|
|
|
279
281
|
flag CI can accept and never satisfy is worse than no flag. In CI, set the
|
|
280
282
|
three variables as secrets.
|
|
281
283
|
|
|
284
|
+
It also brings back **where Google puts each page**, and what it is found for:
|
|
285
|
+
|
|
286
|
+
- Every finding on a page Google ranks carries that page's average position, so
|
|
287
|
+
the report can put a template on page two ahead of one nobody has been shown.
|
|
288
|
+
Positions were in every response Google has ever sent and were being discarded.
|
|
289
|
+
- **`search-console-striking`** names the crawled pages sitting at positions 11
|
|
290
|
+
to 20 — page two, where the click-through rate is roughly nothing and the
|
|
291
|
+
ranking is already earned — with the query each one is closest on. It is the
|
|
292
|
+
only list in this tool that is an opportunity rather than a fault, and every
|
|
293
|
+
number in it was measured by Google. Moving one of those up two places is
|
|
294
|
+
usually less work than a new page.
|
|
295
|
+
|
|
296
|
+
Neither needs a second account, a scrape, or a keyword provider: it is the same
|
|
297
|
+
connection, asked one more question. Google withholds any search too rare to be
|
|
298
|
+
anonymous, so a low-traffic property gets positions and no queries — and the
|
|
299
|
+
report says so rather than leaving silence to read as "found for nothing".
|
|
300
|
+
|
|
282
301
|
The whole setup, the property-naming trap and what each failure note means:
|
|
283
302
|
[docs/search-console.md](docs/search-console.md). A domain property is named `sc-domain:example.com` rather than by
|
|
284
303
|
its URL. Missing credentials, or a property the account cannot read, are a note
|
|
@@ -463,8 +482,10 @@ reason.
|
|
|
463
482
|
| `--exclude <glob>` | — | Leave URLs out of the crawl. Repeatable; `*` stops at a slash, `**` does not |
|
|
464
483
|
| `--dry-run` | — | Say what would be crawled and stop. A handful of requests instead of hundreds |
|
|
465
484
|
| `--write-sitemap <file>` | — | Write the sitemap this site should have had. Refuses on a crawl that did not see the whole site |
|
|
485
|
+
| `--write-llms <file>` | — | Write the `llms.txt` this site should have had, in the [llmstxt.org](https://llmstxt.org) format, from the site's own titles and descriptions. Nothing is generated or rewritten. Refuses on a partial crawl, for the same reason |
|
|
486
|
+
| `--write-schema <file>` | — | Write the JSON-LD this site could add — `WebSite`, `Organization`, `BreadcrumbList` — built **only** from strings the crawl read off the site. A step it cannot name from the site's own words is skipped, never invented from a slug |
|
|
466
487
|
| `--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 |
|
|
467
|
-
| `--csv <file>` | — | Write the
|
|
488
|
+
| `--csv <file>` | — | Write the checklist as a spreadsheet: one row per finding with a `points` column for what fixing it is worth, then the checks that passed (`pass`) and the ones that did not apply (`not-checked`) |
|
|
468
489
|
| `--baseline <file>` | — | Compare against a previous `--json` run; show only what changed |
|
|
469
490
|
| `--update-baseline` | — | Rewrite the baseline after comparing |
|
|
470
491
|
| `--limit <n>` | 200 | Maximum pages to check |
|
|
@@ -480,7 +501,7 @@ reason.
|
|
|
480
501
|
| `--psi <urls>` | — | Measure these pages with PageSpeed Insights. A path glob names a section (see below) |
|
|
481
502
|
| `--psi-sample <n>` | 3 | Pages measured per section glob |
|
|
482
503
|
| `--psi-strategy` | `mobile` | `mobile` or `desktop` |
|
|
483
|
-
| `--against <url>` | — | Compare against another deployment now —
|
|
504
|
+
| `--against <url>` | — | Compare against another deployment now — a rebuild against the site it replaces. Findings are matched by path, so the two hosts need not be the same |
|
|
484
505
|
| `--settle <s>` | — | Wait until the site serves consistent HTML before crawling |
|
|
485
506
|
| `--fail-on <level>` | `error` | Exit 1 at `error`, `warn`, `new`, or `never` |
|
|
486
507
|
| `--version` | — | Print the version |
|
|
@@ -611,6 +632,31 @@ survivable in CI instead of being switched off in week two.
|
|
|
611
632
|
|
|
612
633
|
---
|
|
613
634
|
|
|
635
|
+
## The window, on Linux and Windows
|
|
636
|
+
|
|
637
|
+
The macOS app is a thin client over a local server, and that server runs anywhere Node does:
|
|
638
|
+
|
|
639
|
+
```bash
|
|
640
|
+
npx @nurkamol/seo-audit --serve
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
It opens a browser onto the same window the macOS app draws: a sidebar of kept runs down the left, the report beside it, score ring and all. Everything the command line takes is in the form — the same table that decides what the macOS window reaches decides what this draws, so neither can quietly fall behind the other. Finished runs are kept and listed at `/reports`, two of them can be compared, and on macOS it is **the same folder the app uses**: a crawl started in the window is in the browser's list a second later, because there is one folder rather than two.
|
|
644
|
+
|
|
645
|
+
Nothing leaves the machine. It binds to the loopback address, which is the whole of its security model.
|
|
646
|
+
|
|
647
|
+
There is also a **native window for Windows and Linux** built on exactly this: a
|
|
648
|
+
Tauri shell that starts the same server and shows the same report, in
|
|
649
|
+
[`desktop/`](desktop/README.md). It ships a Node inside it, so there is nothing
|
|
650
|
+
to install first. Every release attaches a `setup.exe`, a `.deb` and an
|
|
651
|
+
AppImage, each built on its own runner and then **installed and run there**
|
|
652
|
+
before it is attached. It tells you when there is a new version and offers
|
|
653
|
+
whatever is safe for the way you installed it — `winget upgrade` in place, or
|
|
654
|
+
the command to run, or the release page.
|
|
655
|
+
|
|
656
|
+
`--no-open` if you would rather it did not open a browser. It opens one when a person ran the command and never when something else did, so the macOS window — which spawns this — is unaffected.
|
|
657
|
+
|
|
658
|
+
---
|
|
659
|
+
|
|
614
660
|
## Hosting it, for people who will not open a terminal
|
|
615
661
|
|
|
616
662
|
Optional, and off the main path. Everything above is free and runs on your own
|
|
@@ -741,6 +787,8 @@ Findings come at three levels: **error** (wrong, and costing traffic), **warning
|
|
|
741
787
|
| No `lastmod` is in the future | warning |
|
|
742
788
|
| A favicon Google can use — the home page declares one that loads, or `/favicon.ico` is there | warning / note |
|
|
743
789
|
| `llms.txt` exists | note |
|
|
790
|
+
| **Which AI crawlers robots.txt lets in** — GPTBot, ClaudeBot, PerplexityBot, Google-Extended, CCBot and eight more, asked of the same parser Google's rules go through. Split by what blocking costs: an *answering* crawler fetches because somebody asked a question just now, a *training* one does not, and blocking the second changes nothing about being cited today. Always a **note**: refusing an AI crawler is a decision a publisher is entitled to make. It also says whether anybody made it — a block that arrives through `User-agent: *` is usually a CDN or plugin default | note |
|
|
791
|
+
| `llms.txt` and `robots.txt` do not contradict each other — a site that serves a file whose only purpose is to tell an assistant what to read, while disallowing the agent that would read it, has one of the two files wrong | warning |
|
|
744
792
|
| 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 |
|
|
745
793
|
| `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 |
|
|
746
794
|
| The TLS certificate is not expired, and not expiring within 14 days | error / warning |
|
|
@@ -760,12 +808,98 @@ Findings come at three levels: **error** (wrong, and costing traffic), **warning
|
|
|
760
808
|
|
|
761
809
|
---
|
|
762
810
|
|
|
811
|
+
## What it can write for you
|
|
812
|
+
|
|
813
|
+
Three of the outputs are not reports. They are the fix, built from what the crawl already read:
|
|
814
|
+
|
|
815
|
+
| | |
|
|
816
|
+
|---|---|
|
|
817
|
+
| `--write-sitemap` | Every page that answered 200, is HTML, is indexable and is its own canonical |
|
|
818
|
+
| `--write-llms` | The [llms.txt](https://llmstxt.org) — the site's own titles and descriptions, grouped by section |
|
|
819
|
+
| `--write-schema` | `WebSite`, `Organization` and `BreadcrumbList` as JSON-LD |
|
|
820
|
+
|
|
821
|
+
All three follow one rule and it is absolute: **every value is a string this crawl read off this site.** Nothing is generated, rewritten, summarised or inferred. A page with no description gets a line without one rather than a sentence somebody made up about it; an organisation is named only when the site names itself in `og:site_name`, because a wrong company name is the worst thing in that file to get wrong.
|
|
822
|
+
|
|
823
|
+
Breadcrumbs make the rule concrete. A `<title>` is not a breadcrumb name — the first live run of this produced `Assets | Jekyll • Simple, blog-aware, static sites` as a step. So a step is named by the page's `<h1>` when it has exactly one, or by the words the site's own navigation uses to link to it, and by nothing else:
|
|
824
|
+
|
|
825
|
+
```
|
|
826
|
+
wrote schema.json — 61 block(s) for 61 page(s)
|
|
827
|
+
60 BreadcrumbList Jekyll › Docs › Assets
|
|
828
|
+
1 Organization
|
|
829
|
+
skipped 142 (a step could not be named from the site's own words)
|
|
830
|
+
skipped 8 (is at the top of the site, so there is no trail to describe)
|
|
831
|
+
skipped 1 (already declares a WebSite)
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
Structured data that describes a site inaccurately is worse than none: it is a machine-readable claim the page does not support, which is a manual-action category at Google. So 142 pages were skipped rather than given a name invented from a URL.
|
|
835
|
+
|
|
836
|
+
And all three refuse outright on a crawl that did not see the whole site, because a file built from a third of a site is worse than no file — it looks complete. The refusal names the run that would work.
|
|
837
|
+
|
|
838
|
+
---
|
|
839
|
+
|
|
840
|
+
## The score
|
|
841
|
+
|
|
842
|
+
Every report opens with a number out of 100, a grade and a ring:
|
|
843
|
+
|
|
844
|
+
```
|
|
845
|
+
https://example.com
|
|
846
|
+
31 pages · 71 requests · 12.9s
|
|
847
|
+
|
|
848
|
+
74/100 C ████████████████████░░░░░░░░
|
|
849
|
+
26.1 points across 9 checks · 61 passed · 22 did not apply
|
|
850
|
+
Clear the errors alone and it is 88.
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
It is a checklist that has been counted, and the arithmetic is small enough to print:
|
|
854
|
+
|
|
855
|
+
| | |
|
|
856
|
+
|---|---|
|
|
857
|
+
| **A run starts at 100** and pays for what is wrong with it | Nothing is added for passing; a clean site is at 100 because nothing took points off |
|
|
858
|
+
| **An error-level check costs 12 points, a warning 4** | Not a new judgement — every check already carries a level, argued over check by check when it was written. `scripts/check-levels.mjs` reads those levels back out of the source and a test asserts the table still matches, so a check promoted from warning to error cannot keep its old weight |
|
|
859
|
+
| **A check on some pages costs its share** | A missing `<h1>` on 3 of 40 pages costs a tenth of a warning; the same fault on all 40 costs the whole of one |
|
|
860
|
+
| **Notes cost nothing** | A note is "worth knowing, may be deliberate". An `llms.txt` nobody wanted is not a fault |
|
|
861
|
+
| **A check that could not run is skipped, not passed** | A site with no images has not passed the alt-text check, and a run without `--psi` has not passed the performance ones. Both would be free points for doing less |
|
|
862
|
+
|
|
863
|
+
So the score is an amount of known, named, locatable work subtracted from a clean sheet — never a prediction of a ranking, an estimate of traffic, or a grade against anybody else's site. What a check costs is what fixing it is worth, and every piece of work under **Start here** carries the points it returns:
|
|
864
|
+
|
|
865
|
+
```
|
|
866
|
+
+4.0 ✗ No <h1> 17 pages under /classes/, 62% of the crawl
|
|
867
|
+
+2.7 ! og:image is WebP 17 pages under /classes/
|
|
868
|
+
+1.3 ! Meta description will be cut off 4 pages under /journal/
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
Two runs of the same site are directly comparable. A run of one site against another is comparable to the degree that the same checks applied to both, which the report says out loud.
|
|
872
|
+
|
|
873
|
+
### What passed, and what was never checked
|
|
874
|
+
|
|
875
|
+
A missing finding reads exactly like a passing one, so both are named. **Passing** lists every check the site cleared, in its own words:
|
|
876
|
+
|
|
877
|
+
```
|
|
878
|
+
✓ Every page has a title
|
|
879
|
+
✓ Every image has an alt attribute
|
|
880
|
+
✓ No og:image is WebP
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
**Not checked** says why the rest did not apply, grouped by reason:
|
|
884
|
+
|
|
885
|
+
```
|
|
886
|
+
· No page declares hreflang. (hreflang-invalid, hreflang-one-way, hreflang-dead, …)
|
|
887
|
+
· PageSpeed was not asked — run with --psi. (psi-score, psi-lcp, psi-cls, …)
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
The full checklist — every scored check with its weight, its area and its pass line — is served at `/checks` when the [hosted front end](#hosting-it-for-people-who-will-not-open-a-terminal) is running, so "what does this thing actually check" is a question with a fetchable answer.
|
|
891
|
+
|
|
892
|
+
---
|
|
893
|
+
|
|
763
894
|
## Reading the output
|
|
764
895
|
|
|
765
896
|
```
|
|
766
897
|
https://example.com
|
|
767
898
|
31 pages · 71 requests · 12.9s
|
|
768
899
|
|
|
900
|
+
74/100 C ████████████████████░░░░░░░░
|
|
901
|
+
26.1 points across 9 checks · 61 passed · 22 did not apply
|
|
902
|
+
|
|
769
903
|
✗ No <h1> ×17
|
|
770
904
|
The page has no headline.
|
|
771
905
|
· https://example.com/schedule/
|
|
@@ -784,7 +918,9 @@ Findings come at three levels: **error** (wrong, and costing traffic), **warning
|
|
|
784
918
|
20 error 91 warning 12 note
|
|
785
919
|
```
|
|
786
920
|
|
|
787
|
-
Findings are grouped by check, not by page, because the fix is usually one change applied everywhere.
|
|
921
|
+
Findings are grouped by check, not by page, because the fix is usually one change applied everywhere. Errors and warnings come first; notes follow under **Worth knowing**, which says out loud that none of them cost the score anything.
|
|
922
|
+
|
|
923
|
+
Then **Passing**, then **Not checked**.
|
|
788
924
|
|
|
789
925
|
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.
|
|
790
926
|
|
|
@@ -826,6 +962,13 @@ Two rules that keep the tool trustworthy:
|
|
|
826
962
|
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`.
|
|
827
963
|
2. **No dependencies.** It must keep running with a bare `npx` on a machine with nothing installed.
|
|
828
964
|
|
|
965
|
+
```bash
|
|
966
|
+
npm test # the engine, the Worker and the extension — no install, any platform
|
|
967
|
+
npm run test:all # and the macOS app's own Swift suite, where there is a toolchain for it
|
|
968
|
+
```
|
|
969
|
+
|
|
970
|
+
There are two suites and `npm test` runs one. Keeping it portable is the point — it works on a machine with nothing on it. `test:all` runs both and says plainly when it could not run the second, rather than exiting green having skipped half the work.
|
|
971
|
+
|
|
829
972
|
See [ROADMAP.md](ROADMAP.md) for what is planned, [CHANGELOG.md](CHANGELOG.md) for what changed.
|
|
830
973
|
|
|
831
974
|
## Licence
|
package/action.yml
CHANGED
|
@@ -91,6 +91,26 @@ inputs:
|
|
|
91
91
|
Where to write the findings as a spreadsheet, one row per finding. Off
|
|
92
92
|
unless a path is given.
|
|
93
93
|
required: false
|
|
94
|
+
write-sitemap:
|
|
95
|
+
description: >
|
|
96
|
+
Where to write the sitemap this site should have had — every page that
|
|
97
|
+
answered 200, is HTML, is indexable and is its own canonical. Refuses on
|
|
98
|
+
a crawl that did not see the whole site rather than writing a short one.
|
|
99
|
+
Off unless a path is given.
|
|
100
|
+
required: false
|
|
101
|
+
write-llms:
|
|
102
|
+
description: >
|
|
103
|
+
Where to write the llms.txt this site should have had, in the
|
|
104
|
+
llmstxt.org format, from the site's own titles and descriptions. Refuses
|
|
105
|
+
on a partial crawl for the same reason as write-sitemap. Off unless a
|
|
106
|
+
path is given.
|
|
107
|
+
required: false
|
|
108
|
+
write-schema:
|
|
109
|
+
description: >
|
|
110
|
+
Where to write the JSON-LD this site could add — WebSite, Organization
|
|
111
|
+
and BreadcrumbList, built only from strings the crawl actually read. Off
|
|
112
|
+
unless a path is given.
|
|
113
|
+
required: false
|
|
94
114
|
comment:
|
|
95
115
|
description: Post the findings as a sticky comment on the pull request
|
|
96
116
|
required: false
|
|
@@ -159,6 +179,9 @@ runs:
|
|
|
159
179
|
[ -n "$INPUT_BROWSER" ] && args+=(--browser "$INPUT_BROWSER")
|
|
160
180
|
[ -n "$INPUT_OS" ] && args+=(--os "$INPUT_OS")
|
|
161
181
|
[ -n "$INPUT_CSV" ] && args+=(--csv "$INPUT_CSV")
|
|
182
|
+
[ -n "$INPUT_WRITE_SITEMAP" ] && args+=(--write-sitemap "$INPUT_WRITE_SITEMAP")
|
|
183
|
+
[ -n "$INPUT_WRITE_LLMS" ] && args+=(--write-llms "$INPUT_WRITE_LLMS")
|
|
184
|
+
[ -n "$INPUT_WRITE_SCHEMA" ] && args+=(--write-schema "$INPUT_WRITE_SCHEMA")
|
|
162
185
|
|
|
163
186
|
node "$SEO_AUDIT_BIN" "${args[@]}"
|
|
164
187
|
status=$?
|
package/bin/seo-audit.mjs
CHANGED
|
@@ -9,6 +9,8 @@ import { dirname, join } from 'node:path';
|
|
|
9
9
|
import { serialize, parse, diff } from '../src/baseline.mjs';
|
|
10
10
|
import { parseRedirectMap } from '../src/redirects.mjs';
|
|
11
11
|
import { describe } from '../src/sitemap.mjs';
|
|
12
|
+
import { describeLlms } from '../src/llms.mjs';
|
|
13
|
+
import { describeSchema } from '../src/schema.mjs';
|
|
12
14
|
import { askForSite, isInteractive, invocation } from '../src/prompt.mjs';
|
|
13
15
|
import { userAgentFor, BROWSER_NAMES, OS_NAMES, thisPlatform } from '../src/agents.mjs';
|
|
14
16
|
|
|
@@ -32,6 +34,17 @@ const HELP = `
|
|
|
32
34
|
page that answered 200, is HTML, is indexable and is
|
|
33
35
|
its own canonical. Refuses on a crawl that did not
|
|
34
36
|
see the whole site, rather than writing a short one
|
|
37
|
+
--write-llms <file>
|
|
38
|
+
write the llms.txt this site should have had — its own
|
|
39
|
+
titles and descriptions, grouped by section, in the
|
|
40
|
+
llmstxt.org format. Nothing is generated or rewritten,
|
|
41
|
+
and it refuses on a partial crawl for the same reason
|
|
42
|
+
--write-schema <file>
|
|
43
|
+
write the JSON-LD this site could add: WebSite,
|
|
44
|
+
Organization and BreadcrumbList, built only from
|
|
45
|
+
strings the crawl actually read. A page whose trail
|
|
46
|
+
has an uncrawled step is skipped rather than given a
|
|
47
|
+
name invented from its slug
|
|
35
48
|
--since <date> crawl only URLs the sitemap says changed on or after
|
|
36
49
|
this date. Refuses when lastmod cannot answer it —
|
|
37
50
|
absent, or one build stamp on every URL
|
|
@@ -66,6 +79,9 @@ const HELP = `
|
|
|
66
79
|
--check-external also check links pointing off the site. Off by default:
|
|
67
80
|
other people's servers rate-limit and bot-block, so only
|
|
68
81
|
a 404, a 410 or no answer at all is ever reported
|
|
82
|
+
--no-open with --serve, do not open a browser. It opens one when
|
|
83
|
+
a person ran the command and never when something else
|
|
84
|
+
did, so this is only for the person who wants neither
|
|
69
85
|
--serve [port] open the same form the hosted version serves, on this
|
|
70
86
|
machine (default 4321). No account, no bill, and none of
|
|
71
87
|
the limits a Worker has — the crawl is only bounded by
|
|
@@ -140,6 +156,9 @@ function parseArgs(argv) {
|
|
|
140
156
|
// comma-separated list, and a URL can contain a comma.
|
|
141
157
|
else if (arg === '--exclude') (opts.exclude ??= []).push(value());
|
|
142
158
|
else if (arg === '--write-sitemap') opts.writeSitemap = value();
|
|
159
|
+
else if (arg === '--write-llms') opts.writeLlms = value();
|
|
160
|
+
else if (arg === '--write-schema') opts.writeSchema = value();
|
|
161
|
+
else if (arg === '--no-open') opts.noOpen = true;
|
|
143
162
|
else if (arg === '--md') opts.md = value();
|
|
144
163
|
else if (arg === '--html') opts.html = value();
|
|
145
164
|
else if (arg === '--json') opts.json = value();
|
|
@@ -320,6 +339,19 @@ if (opts.serve !== undefined) {
|
|
|
320
339
|
process.stdin.on('end', () => process.exit(0));
|
|
321
340
|
process.stdin.on('close', () => process.exit(0));
|
|
322
341
|
}
|
|
342
|
+
|
|
343
|
+
// Opened for a person, never for a parent. The same distinction the pipe
|
|
344
|
+
// check above already makes: somebody who typed `--serve` wants the page,
|
|
345
|
+
// and the macOS window — which spawns this and draws its own report — would
|
|
346
|
+
// get a browser it never asked for on every launch.
|
|
347
|
+
//
|
|
348
|
+
// This is the whole of "the desktop UI for Linux and Windows": a command that
|
|
349
|
+
// opens a window. Failing to open one is not a reason to refuse to serve, so
|
|
350
|
+
// the URL is printed either way and nothing here throws.
|
|
351
|
+
if (!stdinIsPipe && !opts.noOpen) {
|
|
352
|
+
const { openUrl } = await import('../src/open-url.mjs');
|
|
353
|
+
if (!openUrl(url)) console.log(' Open that address yourself — this system has no launcher I know.\n');
|
|
354
|
+
}
|
|
323
355
|
} else {
|
|
324
356
|
|
|
325
357
|
// --- sign in, and stop ----------------------------------------------------
|
|
@@ -402,12 +434,12 @@ if (sites.length > 1) {
|
|
|
402
434
|
const siteOpts = optionsForSite(opts, site.overrides);
|
|
403
435
|
// Sites run one at a time on purpose: interleaved progress from twenty
|
|
404
436
|
// hosts is unreadable, and each audit is already parallel internally.
|
|
405
|
-
const { findings, meta } = await audit(site.url, {
|
|
437
|
+
const { findings, meta, score } = await audit(site.url, {
|
|
406
438
|
...siteOpts,
|
|
407
439
|
onNote: (m) => !opts.quiet && process.stderr.write(` ${m}\n`),
|
|
408
440
|
onProgress: live(site.url),
|
|
409
441
|
});
|
|
410
|
-
runs.push({ findings, meta });
|
|
442
|
+
runs.push({ findings, meta, score });
|
|
411
443
|
}
|
|
412
444
|
|
|
413
445
|
if (!opts.quiet) console.log(portfolio(runs));
|
|
@@ -417,7 +449,11 @@ if (sites.length > 1) {
|
|
|
417
449
|
writeFileSync(
|
|
418
450
|
opts.json,
|
|
419
451
|
JSON.stringify(
|
|
420
|
-
{
|
|
452
|
+
{
|
|
453
|
+
tool: 'seo-audit',
|
|
454
|
+
date: runs[0]?.meta.date,
|
|
455
|
+
sites: runs.map((r) => ({ ...r.meta, score: r.score, findings: r.findings })),
|
|
456
|
+
},
|
|
421
457
|
null,
|
|
422
458
|
2,
|
|
423
459
|
),
|
|
@@ -455,7 +491,7 @@ if (!opts.quiet && opts.settle) {
|
|
|
455
491
|
process.stderr.write(` waiting up to ${opts.settle}s for the site to serve consistent HTML …\n`);
|
|
456
492
|
}
|
|
457
493
|
|
|
458
|
-
const { findings, meta, sitemap } = await audit(target, {
|
|
494
|
+
const { findings, meta, sitemap, llms, schema, score } = await audit(target, {
|
|
459
495
|
...opts,
|
|
460
496
|
onNote: (m) => !opts.quiet && process.stderr.write(` ${m}\n`),
|
|
461
497
|
onProgress: live(target),
|
|
@@ -468,6 +504,14 @@ if (opts.writeSitemap && sitemap) {
|
|
|
468
504
|
if (sitemap.xml) writeFileSync(opts.writeSitemap, sitemap.xml);
|
|
469
505
|
process.stderr.write('\n' + describe(sitemap, opts.writeSitemap));
|
|
470
506
|
}
|
|
507
|
+
if (opts.writeLlms && llms) {
|
|
508
|
+
if (llms.text) writeFileSync(opts.writeLlms, llms.text);
|
|
509
|
+
process.stderr.write('\n' + describeLlms(llms, opts.writeLlms));
|
|
510
|
+
}
|
|
511
|
+
if (opts.writeSchema && schema) {
|
|
512
|
+
if (schema.json) writeFileSync(opts.writeSchema, schema.json);
|
|
513
|
+
process.stderr.write('\n' + describeSchema(schema, opts.writeSchema));
|
|
514
|
+
}
|
|
471
515
|
|
|
472
516
|
// --- Compare against another deployment, if asked -----------------------
|
|
473
517
|
let against = null;
|
|
@@ -499,12 +543,12 @@ if (opts.baseline && !against) {
|
|
|
499
543
|
|
|
500
544
|
// --- Report -------------------------------------------------------------
|
|
501
545
|
if (!opts.quiet) {
|
|
502
|
-
console.log(comparison ? diffReport(comparison) : terminal(findings, meta));
|
|
546
|
+
console.log(comparison ? diffReport(comparison) : terminal(findings, meta, { score }));
|
|
503
547
|
}
|
|
504
|
-
if (opts.md) writeFileSync(opts.md, markdown(findings, meta));
|
|
505
|
-
if (opts.html) writeFileSync(opts.html, html(findings, meta));
|
|
506
|
-
if (opts.json) writeFileSync(opts.json, serialize(findings, meta, { full: true }));
|
|
507
|
-
if (opts.csv) writeFileSync(opts.csv, csv(findings, meta));
|
|
548
|
+
if (opts.md) writeFileSync(opts.md, markdown(findings, meta, { score }));
|
|
549
|
+
if (opts.html) writeFileSync(opts.html, html(findings, meta, { score }));
|
|
550
|
+
if (opts.json) writeFileSync(opts.json, serialize(findings, meta, { full: true, score }));
|
|
551
|
+
if (opts.csv) writeFileSync(opts.csv, csv(findings, meta, { score }));
|
|
508
552
|
if (!opts.quiet && (opts.md || opts.html || opts.json)) {
|
|
509
553
|
console.log(` ${[opts.md, opts.html, opts.json].filter(Boolean).join(' ')}\n`);
|
|
510
554
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurkamol/seo-audit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.35.0",
|
|
4
4
|
"description": "Crawl a site's sitemap and check every page for SEO, metadata and structured-data problems that single-page graders miss. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
"exports": {
|
|
10
10
|
".": "./src/audit.mjs",
|
|
11
11
|
"./causes": "./src/causes.mjs",
|
|
12
|
+
"./exports": "./src/exports.mjs",
|
|
12
13
|
"./report": "./src/report.mjs",
|
|
14
|
+
"./score": "./src/score.mjs",
|
|
13
15
|
"./sitemap": "./src/sitemap.mjs",
|
|
14
16
|
"./areas": "./src/areas.mjs",
|
|
15
17
|
"./agents": "./src/agents.mjs",
|
|
@@ -28,6 +30,8 @@
|
|
|
28
30
|
"scripts": {
|
|
29
31
|
"pretest": "node scripts/link-engine.mjs",
|
|
30
32
|
"test": "node --test test/*.test.mjs",
|
|
33
|
+
"pretest:all": "node scripts/link-engine.mjs",
|
|
34
|
+
"test:all": "node scripts/test-all.mjs",
|
|
31
35
|
"audit": "node bin/seo-audit.mjs"
|
|
32
36
|
},
|
|
33
37
|
"keywords": [
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Which AI crawlers a site lets in, and which it does not.
|
|
2
|
+
//
|
|
3
|
+
// The answer engines fetch with their own user agents and obey robots.txt like
|
|
4
|
+
// anything else, so a site's position on being read by them is already written
|
|
5
|
+
// down in a file it already serves — nobody has to be asked and nothing has to
|
|
6
|
+
// be estimated. That is the whole of this module: `robotsVerdict()` has taken
|
|
7
|
+
// an agent since it was written, and this hands it the ten names that matter.
|
|
8
|
+
//
|
|
9
|
+
// **Blocking these is not a fault.** A publisher who does not want their work
|
|
10
|
+
// in a model's training set and says so in robots.txt has done the correct
|
|
11
|
+
// thing, correctly. So the finding is a note, phrased as a fact, and the report
|
|
12
|
+
// is careful never to imply otherwise — the rule about checks that cry wolf
|
|
13
|
+
// applies with particular force to a check somebody could reasonably have
|
|
14
|
+
// meant. What it is for is the case nobody chose: a CDN, a WordPress plugin or
|
|
15
|
+
// a hosting default that added `Disallow` lines the site's owner has never
|
|
16
|
+
// seen, and which quietly costs them every citation in every AI answer.
|
|
17
|
+
|
|
18
|
+
/** The agents worth asking about, and what each one is for.
|
|
19
|
+
*
|
|
20
|
+
* Two things are deliberately distinguished, because conflating them is the
|
|
21
|
+
* usual mistake: **training** crawlers gather text to train on, and blocking
|
|
22
|
+
* one changes nothing about whether you can be cited today. **Answering**
|
|
23
|
+
* crawlers fetch a page because somebody asked a question just now, and
|
|
24
|
+
* blocking one removes you from that answer. A site that meant to opt out of
|
|
25
|
+
* training and blocked both has almost certainly not read the difference. */
|
|
26
|
+
export const AI_AGENTS = [
|
|
27
|
+
{ token: 'gptbot', name: 'GPTBot', vendor: 'OpenAI', purpose: 'training' },
|
|
28
|
+
{ token: 'oai-searchbot', name: 'OAI-SearchBot', vendor: 'OpenAI', purpose: 'answering' },
|
|
29
|
+
{ token: 'chatgpt-user', name: 'ChatGPT-User', vendor: 'OpenAI', purpose: 'answering' },
|
|
30
|
+
{ token: 'claudebot', name: 'ClaudeBot', vendor: 'Anthropic', purpose: 'training' },
|
|
31
|
+
{ token: 'claude-searchbot', name: 'Claude-SearchBot', vendor: 'Anthropic', purpose: 'answering' },
|
|
32
|
+
{ token: 'claude-user', name: 'Claude-User', vendor: 'Anthropic', purpose: 'answering' },
|
|
33
|
+
{ token: 'perplexitybot', name: 'PerplexityBot', vendor: 'Perplexity', purpose: 'answering' },
|
|
34
|
+
{ token: 'perplexity-user', name: 'Perplexity-User', vendor: 'Perplexity', purpose: 'answering' },
|
|
35
|
+
{ token: 'google-extended', name: 'Google-Extended', vendor: 'Google', purpose: 'training' },
|
|
36
|
+
{ token: 'applebot-extended', name: 'Applebot-Extended', vendor: 'Apple', purpose: 'training' },
|
|
37
|
+
{ token: 'meta-externalagent', name: 'meta-externalagent', vendor: 'Meta', purpose: 'training' },
|
|
38
|
+
{ token: 'ccbot', name: 'CCBot', vendor: 'Common Crawl', purpose: 'training' },
|
|
39
|
+
{ token: 'bytespider', name: 'Bytespider', vendor: 'ByteDance', purpose: 'training' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/** Whether an agent's own name appears anywhere in the file.
|
|
43
|
+
*
|
|
44
|
+
* The difference between a decision and a default. `User-agent: *` with
|
|
45
|
+
* `Disallow: /private` blocks GPTBot from /private without anybody having
|
|
46
|
+
* thought about GPTBot; `User-agent: GPTBot` with `Disallow: /` is somebody
|
|
47
|
+
* who did. Only the second is a position, and only the first is worth telling
|
|
48
|
+
* a site's owner about. */
|
|
49
|
+
const named = (groups, token) => groups.some((group) => group.agents.includes(token));
|
|
50
|
+
|
|
51
|
+
/** How each AI agent is treated at the site root.
|
|
52
|
+
*
|
|
53
|
+
* `verdict` is asked of the same function every other robots question in this
|
|
54
|
+
* project goes through, so a rule this reads and a rule Google reads are the
|
|
55
|
+
* same rule — including the part where a longer `Allow` beats a `Disallow`.
|
|
56
|
+
*
|
|
57
|
+
* @returns {{ agent: object, allowed: boolean, explicit: boolean }[]}
|
|
58
|
+
*/
|
|
59
|
+
export function aiAccess(groups, robotsVerdict, path = '/') {
|
|
60
|
+
return AI_AGENTS.map((agent) => ({
|
|
61
|
+
agent,
|
|
62
|
+
allowed: robotsVerdict(groups, path, agent.token).allowed,
|
|
63
|
+
explicit: named(groups, agent.token),
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One sentence naming who is shut out, split by what shutting them out costs.
|
|
68
|
+
*
|
|
69
|
+
* Returns null when everybody is let in, because a report saying "nothing is
|
|
70
|
+
* blocked" once per site is a line nobody reads and this project has enough
|
|
71
|
+
* of those already. */
|
|
72
|
+
export function describeAccess(access) {
|
|
73
|
+
const blocked = access.filter((row) => !row.allowed);
|
|
74
|
+
if (!blocked.length) return null;
|
|
75
|
+
|
|
76
|
+
const answering = blocked.filter((row) => row.agent.purpose === 'answering');
|
|
77
|
+
const training = blocked.filter((row) => row.agent.purpose === 'training');
|
|
78
|
+
const list = (rows) => rows.map((row) => row.agent.name).join(', ');
|
|
79
|
+
|
|
80
|
+
const parts = [];
|
|
81
|
+
if (answering.length) {
|
|
82
|
+
parts.push(
|
|
83
|
+
`${list(answering)} fetch a page because somebody asked a question just now, so blocking them ` +
|
|
84
|
+
'removes the site from those answers today',
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (training.length) {
|
|
88
|
+
parts.push(
|
|
89
|
+
`${list(training)} gather text to train on, and blocking them changes nothing about whether the ` +
|
|
90
|
+
'site can be cited today',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
// Whether anybody actually decided this. A block that arrives through
|
|
94
|
+
// `User-agent: *` is usually a default nobody chose.
|
|
95
|
+
const decided = blocked.some((row) => row.explicit);
|
|
96
|
+
parts.push(
|
|
97
|
+
decided
|
|
98
|
+
? 'These are named in robots.txt, so this looks deliberate — this is a note, not a fault'
|
|
99
|
+
: 'None of these are named in robots.txt: they are caught by a `User-agent: *` rule, which is ' +
|
|
100
|
+
'usually a CDN or plugin default rather than a decision anybody made',
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
blocked,
|
|
105
|
+
answering,
|
|
106
|
+
training,
|
|
107
|
+
decided,
|
|
108
|
+
detail: `${parts.join('. ')}.`,
|
|
109
|
+
};
|
|
110
|
+
}
|
package/src/areas.mjs
CHANGED
|
@@ -22,6 +22,7 @@ export const CATEGORIES = [
|
|
|
22
22
|
'Structured data',
|
|
23
23
|
'Multilingual',
|
|
24
24
|
'Sitemap & robots',
|
|
25
|
+
'AI & answer engines',
|
|
25
26
|
'Site & security',
|
|
26
27
|
'Performance',
|
|
27
28
|
];
|
|
@@ -64,6 +65,7 @@ const CATEGORY_OF = {
|
|
|
64
65
|
'serves-differently': 'Indexability', 'compare-sampled': 'Indexability',
|
|
65
66
|
'search-console': 'Site & security', 'search-console-unconfigured': 'Site & security',
|
|
66
67
|
'search-console-failed': 'Site & security',
|
|
68
|
+
'search-console-striking': 'Site & security',
|
|
67
69
|
'trailing-slash': 'Redirects', 'meta-refresh': 'Redirects',
|
|
68
70
|
'redirect-dead': 'Redirects', 'redirect-broken': 'Redirects',
|
|
69
71
|
'redirect-not-applied': 'Redirects', 'redirect-hops': 'Redirects',
|
|
@@ -105,11 +107,20 @@ const CATEGORY_OF = {
|
|
|
105
107
|
'robots-missing': 'Sitemap & robots', 'robots-blocks-all': 'Sitemap & robots',
|
|
106
108
|
'robots-no-sitemap': 'Sitemap & robots', 'robots-blocks-sitemap-url': 'Sitemap & robots',
|
|
107
109
|
'sitemap-lastmod-missing': 'Sitemap & robots', 'sitemap-lastmod-identical': 'Sitemap & robots',
|
|
108
|
-
'sitemap-lastmod-future': 'Sitemap & robots',
|
|
110
|
+
'sitemap-lastmod-future': 'Sitemap & robots',
|
|
109
111
|
'sitemap-duplicate-url': 'Sitemap & robots',
|
|
110
112
|
'sitemap-not-indexable': 'Sitemap & robots', 'sitemap-too-many-urls': 'Sitemap & robots',
|
|
111
113
|
'sitemap-too-large': 'Sitemap & robots',
|
|
112
114
|
|
|
115
|
+
// AI & answer engines
|
|
116
|
+
// What the answer engines are allowed to read, which is a question robots.txt
|
|
117
|
+
// already answers and nothing here estimates. llms.txt moved out of
|
|
118
|
+
// "Sitemap & robots" when this area arrived: it is addressed to assistants,
|
|
119
|
+
// not to crawlers, and it belongs beside the agents that read it.
|
|
120
|
+
'llms-missing': 'AI & answer engines',
|
|
121
|
+
'ai-crawler-blocked': 'AI & answer engines',
|
|
122
|
+
'ai-crawler-conflict': 'AI & answer engines',
|
|
123
|
+
|
|
113
124
|
// Site & security
|
|
114
125
|
'favicon-broken': 'Site & security', 'favicon-missing': 'Site & security',
|
|
115
126
|
'mixed-content': 'Site & security', 'tls-not-checked': 'Site & security',
|
package/src/audit.mjs
CHANGED
|
@@ -7,6 +7,9 @@ import { pageChecks, crossPageChecks, sitemapChecks } from './checks.mjs';
|
|
|
7
7
|
import { certificateExpiry, siteChecks } from './site.mjs';
|
|
8
8
|
import { linkGraph } from './graph.mjs';
|
|
9
9
|
import { compareAgents } from './compare.mjs';
|
|
10
|
+
import { scoreRun } from './score.mjs';
|
|
11
|
+
import { buildLlms } from './llms.mjs';
|
|
12
|
+
import { buildSchema } from './schema.mjs';
|
|
10
13
|
import { searchConsole } from './console.mjs';
|
|
11
14
|
import { applyIgnores, expectationChecks, matchGlob } from './config.mjs';
|
|
12
15
|
import { psiChecks, psiTargets, estimateSeconds } from './psi.mjs';
|
|
@@ -632,10 +635,66 @@ export async function audit(target, opts = {}) {
|
|
|
632
635
|
});
|
|
633
636
|
}
|
|
634
637
|
|
|
638
|
+
// What this run was in a position to check at all. A site with no images has
|
|
639
|
+
// not passed the alt-text check and a run without --psi has not passed the
|
|
640
|
+
// performance ones, and a score that counted either as a pass would hand out
|
|
641
|
+
// free points for doing less. Worked out here because this is the only place
|
|
642
|
+
// that has both the crawl and the options — a front end reading the JSON
|
|
643
|
+
// gets it in `meta` rather than guessing.
|
|
644
|
+
const some = (fn) => pages.some((p) => p.doc && fn(p.doc));
|
|
645
|
+
const applicable = {
|
|
646
|
+
images: some((d) => d.images.length > 0),
|
|
647
|
+
hreflang: some((d) => d.hreflang.length > 0),
|
|
648
|
+
jsonld: some((d) => d.jsonld.length > 0),
|
|
649
|
+
ogImage: some((d) => Boolean(d.og['og:image'])),
|
|
650
|
+
twitterImage: some((d) => Boolean(d.twitter['twitter:image'])),
|
|
651
|
+
fingerprints: some((d) => d.fingerprint !== null),
|
|
652
|
+
multipage: pages.length > 1,
|
|
653
|
+
https: origin.startsWith('https:'),
|
|
654
|
+
// Certificates need a TLS socket. Node has one; the Workers runtime does
|
|
655
|
+
// not, and says so by handing in a reader that returns nothing.
|
|
656
|
+
tls: origin.startsWith('https:') && opts.readCertificateExpiry === undefined,
|
|
657
|
+
sitemap: Boolean(source),
|
|
658
|
+
// Only a site that serves llms.txt can contradict it. `llms-missing` is
|
|
659
|
+
// emitted exactly when it is absent, so the run already knows.
|
|
660
|
+
llmsTxt: !kept.some((finding) => finding.id === 'llms-missing'),
|
|
661
|
+
expect: Boolean(opts.expect?.length),
|
|
662
|
+
psi: Boolean(opts.psi?.length),
|
|
663
|
+
// Field data is Google's, not ours: it exists for a page or it does not,
|
|
664
|
+
// and PageSpeed says which by returning nothing. One page short of it is
|
|
665
|
+
// enough to leave the whole family out — under-counting is the honest way
|
|
666
|
+
// round, since the alternative scores a page on data it never had.
|
|
667
|
+
psiField: Boolean(opts.psi?.length) && !kept.some((f) => f.id === 'psi-no-field-data'),
|
|
668
|
+
redirects: Boolean(opts.redirects),
|
|
669
|
+
external: Boolean(opts.checkExternal),
|
|
670
|
+
compareAs: Boolean(opts.compareAs),
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
// The llms.txt this site should have had, from the same crawl and by the
|
|
674
|
+
// same rule as the sitemap: nothing invented, and a refusal rather than a
|
|
675
|
+
// file built from a fraction of the site.
|
|
676
|
+
const llms = opts.writeLlms
|
|
677
|
+
? buildLlms(pages, { origin, truncated, rateLimited: kept.filter((f) => f.id === 'rate-limited').length })
|
|
678
|
+
: null;
|
|
679
|
+
|
|
680
|
+
// The structured data this site could add, from what it already says and
|
|
681
|
+
// nothing else. Same refusals, and a fourth rule of its own: every value is
|
|
682
|
+
// a string this crawl read off this site.
|
|
683
|
+
const schema = opts.writeSchema
|
|
684
|
+
? buildSchema(pages, { origin, truncated, rateLimited: kept.filter((f) => f.id === 'rate-limited').length })
|
|
685
|
+
: null;
|
|
686
|
+
|
|
635
687
|
return {
|
|
636
688
|
findings: kept,
|
|
637
689
|
...(sitemap ? { sitemap } : {}),
|
|
690
|
+
...(llms ? { llms } : {}),
|
|
691
|
+
...(schema ? { schema } : {}),
|
|
692
|
+
// How much of the checklist this site passes. Computed here so that the
|
|
693
|
+
// terminal, the Markdown, the HTML, the window and the extension all show
|
|
694
|
+
// one number rather than five arithmetics that drift apart.
|
|
695
|
+
score: scoreRun(kept, { pages: pages.length, applicable }),
|
|
638
696
|
meta: {
|
|
697
|
+
applicable,
|
|
639
698
|
ignored,
|
|
640
699
|
origin,
|
|
641
700
|
pages: pages.length,
|