@nurkamol/seo-audit 1.33.0 → 1.34.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 +112 -3
- package/action.yml +23 -0
- package/bin/seo-audit.mjs +36 -9
- package/package.json +2 -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/llms.mjs +162 -0
- package/src/options.mjs +2 -0
- package/src/parse.mjs +41 -3
- package/src/report.mjs +489 -61
- package/src/schema.mjs +324 -0
- package/src/score.mjs +410 -0
- package/src/serve.mjs +5 -0
- package/src/site.mjs +36 -1
package/README.md
CHANGED
|
@@ -279,6 +279,23 @@ Interactive by nature, so it is deliberately **not** a GitHub Action input: a
|
|
|
279
279
|
flag CI can accept and never satisfy is worse than no flag. In CI, set the
|
|
280
280
|
three variables as secrets.
|
|
281
281
|
|
|
282
|
+
It also brings back **where Google puts each page**, and what it is found for:
|
|
283
|
+
|
|
284
|
+
- Every finding on a page Google ranks carries that page's average position, so
|
|
285
|
+
the report can put a template on page two ahead of one nobody has been shown.
|
|
286
|
+
Positions were in every response Google has ever sent and were being discarded.
|
|
287
|
+
- **`search-console-striking`** names the crawled pages sitting at positions 11
|
|
288
|
+
to 20 — page two, where the click-through rate is roughly nothing and the
|
|
289
|
+
ranking is already earned — with the query each one is closest on. It is the
|
|
290
|
+
only list in this tool that is an opportunity rather than a fault, and every
|
|
291
|
+
number in it was measured by Google. Moving one of those up two places is
|
|
292
|
+
usually less work than a new page.
|
|
293
|
+
|
|
294
|
+
Neither needs a second account, a scrape, or a keyword provider: it is the same
|
|
295
|
+
connection, asked one more question. Google withholds any search too rare to be
|
|
296
|
+
anonymous, so a low-traffic property gets positions and no queries — and the
|
|
297
|
+
report says so rather than leaving silence to read as "found for nothing".
|
|
298
|
+
|
|
282
299
|
The whole setup, the property-naming trap and what each failure note means:
|
|
283
300
|
[docs/search-console.md](docs/search-console.md). A domain property is named `sc-domain:example.com` rather than by
|
|
284
301
|
its URL. Missing credentials, or a property the account cannot read, are a note
|
|
@@ -463,8 +480,10 @@ reason.
|
|
|
463
480
|
| `--exclude <glob>` | — | Leave URLs out of the crawl. Repeatable; `*` stops at a slash, `**` does not |
|
|
464
481
|
| `--dry-run` | — | Say what would be crawled and stop. A handful of requests instead of hundreds |
|
|
465
482
|
| `--write-sitemap <file>` | — | Write the sitemap this site should have had. Refuses on a crawl that did not see the whole site |
|
|
483
|
+
| `--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 |
|
|
484
|
+
| `--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
485
|
| `--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
|
|
486
|
+
| `--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
487
|
| `--baseline <file>` | — | Compare against a previous `--json` run; show only what changed |
|
|
469
488
|
| `--update-baseline` | — | Rewrite the baseline after comparing |
|
|
470
489
|
| `--limit <n>` | 200 | Maximum pages to check |
|
|
@@ -480,7 +499,7 @@ reason.
|
|
|
480
499
|
| `--psi <urls>` | — | Measure these pages with PageSpeed Insights. A path glob names a section (see below) |
|
|
481
500
|
| `--psi-sample <n>` | 3 | Pages measured per section glob |
|
|
482
501
|
| `--psi-strategy` | `mobile` | `mobile` or `desktop` |
|
|
483
|
-
| `--against <url>` | — | Compare against another deployment now —
|
|
502
|
+
| `--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
503
|
| `--settle <s>` | — | Wait until the site serves consistent HTML before crawling |
|
|
485
504
|
| `--fail-on <level>` | `error` | Exit 1 at `error`, `warn`, `new`, or `never` |
|
|
486
505
|
| `--version` | — | Print the version |
|
|
@@ -741,6 +760,8 @@ Findings come at three levels: **error** (wrong, and costing traffic), **warning
|
|
|
741
760
|
| No `lastmod` is in the future | warning |
|
|
742
761
|
| A favicon Google can use — the home page declares one that loads, or `/favicon.ico` is there | warning / note |
|
|
743
762
|
| `llms.txt` exists | note |
|
|
763
|
+
| **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 |
|
|
764
|
+
| `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
765
|
| 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
766
|
| `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
767
|
| The TLS certificate is not expired, and not expiring within 14 days | error / warning |
|
|
@@ -760,12 +781,98 @@ Findings come at three levels: **error** (wrong, and costing traffic), **warning
|
|
|
760
781
|
|
|
761
782
|
---
|
|
762
783
|
|
|
784
|
+
## What it can write for you
|
|
785
|
+
|
|
786
|
+
Three of the outputs are not reports. They are the fix, built from what the crawl already read:
|
|
787
|
+
|
|
788
|
+
| | |
|
|
789
|
+
|---|---|
|
|
790
|
+
| `--write-sitemap` | Every page that answered 200, is HTML, is indexable and is its own canonical |
|
|
791
|
+
| `--write-llms` | The [llms.txt](https://llmstxt.org) — the site's own titles and descriptions, grouped by section |
|
|
792
|
+
| `--write-schema` | `WebSite`, `Organization` and `BreadcrumbList` as JSON-LD |
|
|
793
|
+
|
|
794
|
+
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.
|
|
795
|
+
|
|
796
|
+
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:
|
|
797
|
+
|
|
798
|
+
```
|
|
799
|
+
wrote schema.json — 61 block(s) for 61 page(s)
|
|
800
|
+
60 BreadcrumbList Jekyll › Docs › Assets
|
|
801
|
+
1 Organization
|
|
802
|
+
skipped 142 (a step could not be named from the site's own words)
|
|
803
|
+
skipped 8 (is at the top of the site, so there is no trail to describe)
|
|
804
|
+
skipped 1 (already declares a WebSite)
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
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.
|
|
808
|
+
|
|
809
|
+
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.
|
|
810
|
+
|
|
811
|
+
---
|
|
812
|
+
|
|
813
|
+
## The score
|
|
814
|
+
|
|
815
|
+
Every report opens with a number out of 100, a grade and a ring:
|
|
816
|
+
|
|
817
|
+
```
|
|
818
|
+
https://example.com
|
|
819
|
+
31 pages · 71 requests · 12.9s
|
|
820
|
+
|
|
821
|
+
74/100 C ████████████████████░░░░░░░░
|
|
822
|
+
26.1 points across 9 checks · 61 passed · 22 did not apply
|
|
823
|
+
Clear the errors alone and it is 88.
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
It is a checklist that has been counted, and the arithmetic is small enough to print:
|
|
827
|
+
|
|
828
|
+
| | |
|
|
829
|
+
|---|---|
|
|
830
|
+
| **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 |
|
|
831
|
+
| **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 |
|
|
832
|
+
| **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 |
|
|
833
|
+
| **Notes cost nothing** | A note is "worth knowing, may be deliberate". An `llms.txt` nobody wanted is not a fault |
|
|
834
|
+
| **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 |
|
|
835
|
+
|
|
836
|
+
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:
|
|
837
|
+
|
|
838
|
+
```
|
|
839
|
+
+4.0 ✗ No <h1> 17 pages under /classes/, 62% of the crawl
|
|
840
|
+
+2.7 ! og:image is WebP 17 pages under /classes/
|
|
841
|
+
+1.3 ! Meta description will be cut off 4 pages under /journal/
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
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.
|
|
845
|
+
|
|
846
|
+
### What passed, and what was never checked
|
|
847
|
+
|
|
848
|
+
A missing finding reads exactly like a passing one, so both are named. **Passing** lists every check the site cleared, in its own words:
|
|
849
|
+
|
|
850
|
+
```
|
|
851
|
+
✓ Every page has a title
|
|
852
|
+
✓ Every image has an alt attribute
|
|
853
|
+
✓ No og:image is WebP
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
**Not checked** says why the rest did not apply, grouped by reason:
|
|
857
|
+
|
|
858
|
+
```
|
|
859
|
+
· No page declares hreflang. (hreflang-invalid, hreflang-one-way, hreflang-dead, …)
|
|
860
|
+
· PageSpeed was not asked — run with --psi. (psi-score, psi-lcp, psi-cls, …)
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
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.
|
|
864
|
+
|
|
865
|
+
---
|
|
866
|
+
|
|
763
867
|
## Reading the output
|
|
764
868
|
|
|
765
869
|
```
|
|
766
870
|
https://example.com
|
|
767
871
|
31 pages · 71 requests · 12.9s
|
|
768
872
|
|
|
873
|
+
74/100 C ████████████████████░░░░░░░░
|
|
874
|
+
26.1 points across 9 checks · 61 passed · 22 did not apply
|
|
875
|
+
|
|
769
876
|
✗ No <h1> ×17
|
|
770
877
|
The page has no headline.
|
|
771
878
|
· https://example.com/schedule/
|
|
@@ -784,7 +891,9 @@ Findings come at three levels: **error** (wrong, and costing traffic), **warning
|
|
|
784
891
|
20 error 91 warning 12 note
|
|
785
892
|
```
|
|
786
893
|
|
|
787
|
-
Findings are grouped by check, not by page, because the fix is usually one change applied everywhere.
|
|
894
|
+
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.
|
|
895
|
+
|
|
896
|
+
Then **Passing**, then **Not checked**.
|
|
788
897
|
|
|
789
898
|
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
899
|
|
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
|
|
@@ -140,6 +153,8 @@ function parseArgs(argv) {
|
|
|
140
153
|
// comma-separated list, and a URL can contain a comma.
|
|
141
154
|
else if (arg === '--exclude') (opts.exclude ??= []).push(value());
|
|
142
155
|
else if (arg === '--write-sitemap') opts.writeSitemap = value();
|
|
156
|
+
else if (arg === '--write-llms') opts.writeLlms = value();
|
|
157
|
+
else if (arg === '--write-schema') opts.writeSchema = value();
|
|
143
158
|
else if (arg === '--md') opts.md = value();
|
|
144
159
|
else if (arg === '--html') opts.html = value();
|
|
145
160
|
else if (arg === '--json') opts.json = value();
|
|
@@ -402,12 +417,12 @@ if (sites.length > 1) {
|
|
|
402
417
|
const siteOpts = optionsForSite(opts, site.overrides);
|
|
403
418
|
// Sites run one at a time on purpose: interleaved progress from twenty
|
|
404
419
|
// hosts is unreadable, and each audit is already parallel internally.
|
|
405
|
-
const { findings, meta } = await audit(site.url, {
|
|
420
|
+
const { findings, meta, score } = await audit(site.url, {
|
|
406
421
|
...siteOpts,
|
|
407
422
|
onNote: (m) => !opts.quiet && process.stderr.write(` ${m}\n`),
|
|
408
423
|
onProgress: live(site.url),
|
|
409
424
|
});
|
|
410
|
-
runs.push({ findings, meta });
|
|
425
|
+
runs.push({ findings, meta, score });
|
|
411
426
|
}
|
|
412
427
|
|
|
413
428
|
if (!opts.quiet) console.log(portfolio(runs));
|
|
@@ -417,7 +432,11 @@ if (sites.length > 1) {
|
|
|
417
432
|
writeFileSync(
|
|
418
433
|
opts.json,
|
|
419
434
|
JSON.stringify(
|
|
420
|
-
{
|
|
435
|
+
{
|
|
436
|
+
tool: 'seo-audit',
|
|
437
|
+
date: runs[0]?.meta.date,
|
|
438
|
+
sites: runs.map((r) => ({ ...r.meta, score: r.score, findings: r.findings })),
|
|
439
|
+
},
|
|
421
440
|
null,
|
|
422
441
|
2,
|
|
423
442
|
),
|
|
@@ -455,7 +474,7 @@ if (!opts.quiet && opts.settle) {
|
|
|
455
474
|
process.stderr.write(` waiting up to ${opts.settle}s for the site to serve consistent HTML …\n`);
|
|
456
475
|
}
|
|
457
476
|
|
|
458
|
-
const { findings, meta, sitemap } = await audit(target, {
|
|
477
|
+
const { findings, meta, sitemap, llms, schema, score } = await audit(target, {
|
|
459
478
|
...opts,
|
|
460
479
|
onNote: (m) => !opts.quiet && process.stderr.write(` ${m}\n`),
|
|
461
480
|
onProgress: live(target),
|
|
@@ -468,6 +487,14 @@ if (opts.writeSitemap && sitemap) {
|
|
|
468
487
|
if (sitemap.xml) writeFileSync(opts.writeSitemap, sitemap.xml);
|
|
469
488
|
process.stderr.write('\n' + describe(sitemap, opts.writeSitemap));
|
|
470
489
|
}
|
|
490
|
+
if (opts.writeLlms && llms) {
|
|
491
|
+
if (llms.text) writeFileSync(opts.writeLlms, llms.text);
|
|
492
|
+
process.stderr.write('\n' + describeLlms(llms, opts.writeLlms));
|
|
493
|
+
}
|
|
494
|
+
if (opts.writeSchema && schema) {
|
|
495
|
+
if (schema.json) writeFileSync(opts.writeSchema, schema.json);
|
|
496
|
+
process.stderr.write('\n' + describeSchema(schema, opts.writeSchema));
|
|
497
|
+
}
|
|
471
498
|
|
|
472
499
|
// --- Compare against another deployment, if asked -----------------------
|
|
473
500
|
let against = null;
|
|
@@ -499,12 +526,12 @@ if (opts.baseline && !against) {
|
|
|
499
526
|
|
|
500
527
|
// --- Report -------------------------------------------------------------
|
|
501
528
|
if (!opts.quiet) {
|
|
502
|
-
console.log(comparison ? diffReport(comparison) : terminal(findings, meta));
|
|
529
|
+
console.log(comparison ? diffReport(comparison) : terminal(findings, meta, { score }));
|
|
503
530
|
}
|
|
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));
|
|
531
|
+
if (opts.md) writeFileSync(opts.md, markdown(findings, meta, { score }));
|
|
532
|
+
if (opts.html) writeFileSync(opts.html, html(findings, meta, { score }));
|
|
533
|
+
if (opts.json) writeFileSync(opts.json, serialize(findings, meta, { full: true, score }));
|
|
534
|
+
if (opts.csv) writeFileSync(opts.csv, csv(findings, meta, { score }));
|
|
508
535
|
if (!opts.quiet && (opts.md || opts.html || opts.json)) {
|
|
509
536
|
console.log(` ${[opts.md, opts.html, opts.json].filter(Boolean).join(' ')}\n`);
|
|
510
537
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurkamol/seo-audit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.34.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": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
".": "./src/audit.mjs",
|
|
11
11
|
"./causes": "./src/causes.mjs",
|
|
12
12
|
"./report": "./src/report.mjs",
|
|
13
|
+
"./score": "./src/score.mjs",
|
|
13
14
|
"./sitemap": "./src/sitemap.mjs",
|
|
14
15
|
"./areas": "./src/areas.mjs",
|
|
15
16
|
"./agents": "./src/agents.mjs",
|
|
@@ -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,
|
package/src/baseline.mjs
CHANGED
|
@@ -5,18 +5,65 @@
|
|
|
5
5
|
// something that worked yesterday" is the question worth failing a build over.
|
|
6
6
|
import { causePayload } from './causes.mjs';
|
|
7
7
|
|
|
8
|
-
/** A finding's identity across runs: the check, and where it happened.
|
|
9
|
-
|
|
8
|
+
/** A finding's identity across runs: the check, and where it happened.
|
|
9
|
+
*
|
|
10
|
+
* `where` is the whole URL when both runs are of the same site, and the path
|
|
11
|
+
* alone when they are not. Comparing a staging deployment with production is
|
|
12
|
+
* the same question as comparing yesterday with today — "did this change
|
|
13
|
+
* anything" — but every URL differs by its host, and keying on the whole URL
|
|
14
|
+
* answered it with every finding fixed and every finding added, which is no
|
|
15
|
+
* answer at all. `--against` has documented "hosts are ignored" since it
|
|
16
|
+
* shipped and passed an option `diff` never read. */
|
|
17
|
+
const key = (f, ignoreHost) => `${f.id}\u0000${ignoreHost ? path(f.url) : (f.url ?? '')}`;
|
|
18
|
+
|
|
19
|
+
/** The part of a URL that is the same page on two different hosts. The query
|
|
20
|
+
* stays: /search?q=a and /search?q=b are two pages. The fragment goes, since
|
|
21
|
+
* no server ever sees it. A URL that will not parse is compared as it is. */
|
|
22
|
+
function path(url) {
|
|
23
|
+
if (!url) return '';
|
|
24
|
+
try {
|
|
25
|
+
const parsed = new URL(url);
|
|
26
|
+
// A trailing slash is not a difference between deployments; one platform
|
|
27
|
+
// serves /about and the next /about/.
|
|
28
|
+
return `${parsed.pathname.replace(/\/$/, '') || '/'}${parsed.search}`;
|
|
29
|
+
} catch {
|
|
30
|
+
return url;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const originOf = (url) => {
|
|
35
|
+
try {
|
|
36
|
+
return new URL(url).origin;
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Compare two runs.
|
|
43
|
+
*
|
|
44
|
+
* `ignoreHost` compares by path rather than by URL. Left unset it decides for
|
|
45
|
+
* itself, by asking whether the two runs are even of the same origin — the
|
|
46
|
+
* Mac app and the hosted `/diff` both post two whole runs and neither should
|
|
47
|
+
* have to know this rule. */
|
|
48
|
+
export function diff(previous, current, { ignoreHost, currentMeta } = {}) {
|
|
49
|
+
const wasOrigin = previous.meta?.origin ?? originOf(previous.findings?.find((f) => f.url)?.url);
|
|
50
|
+
const isOrigin = currentMeta?.origin ?? originOf(current.find((f) => f.url)?.url);
|
|
51
|
+
const crossSite = ignoreHost ?? Boolean(wasOrigin && isOrigin && wasOrigin !== isOrigin);
|
|
10
52
|
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
const after = new Map(current.map((f) => [key(f), f]));
|
|
53
|
+
const before = new Map((previous.findings ?? []).map((f) => [key(f, crossSite), f]));
|
|
54
|
+
const after = new Map(current.map((f) => [key(f, crossSite), f]));
|
|
14
55
|
|
|
15
|
-
const added = current.filter((f) => !before.has(key(f)));
|
|
16
|
-
const fixed = [...before.values()].filter((f) => !after.has(key(f)));
|
|
56
|
+
const added = current.filter((f) => !before.has(key(f, crossSite)));
|
|
57
|
+
const fixed = [...before.values()].filter((f) => !after.has(key(f, crossSite)));
|
|
17
58
|
const unchanged = current.length - added.length;
|
|
18
59
|
|
|
19
|
-
return {
|
|
60
|
+
return {
|
|
61
|
+
added,
|
|
62
|
+
fixed,
|
|
63
|
+
unchanged,
|
|
64
|
+
crossSite,
|
|
65
|
+
previousDate: previous.meta?.date ?? 'the baseline',
|
|
66
|
+
};
|
|
20
67
|
}
|
|
21
68
|
|
|
22
69
|
/** The run as JSON.
|
|
@@ -30,7 +77,7 @@ export function diff(previous, current) {
|
|
|
30
77
|
*
|
|
31
78
|
* The shape is versioned either way, so a field added later can never make an
|
|
32
79
|
* old baseline silently mis-compare. */
|
|
33
|
-
export function serialize(findings, meta, { full = false } = {}) {
|
|
80
|
+
export function serialize(findings, meta, { full = false, score } = {}) {
|
|
34
81
|
return JSON.stringify(
|
|
35
82
|
{
|
|
36
83
|
version: 1,
|
|
@@ -51,6 +98,11 @@ export function serialize(findings, meta, { full = false } = {}) {
|
|
|
51
98
|
// baseline already holds, and it moves whenever page counts do, which is
|
|
52
99
|
// the churn the baseline shape exists to avoid.
|
|
53
100
|
...(full ? { causes: causePayload(findings, meta.pages ?? 0) } : {}),
|
|
101
|
+
// Deliberately absent from a baseline for the same reason as `causes`:
|
|
102
|
+
// it is derived from the findings a baseline already holds, and it moves
|
|
103
|
+
// whenever a page count does. A baseline whose git diff churns is a
|
|
104
|
+
// baseline nobody reads.
|
|
105
|
+
...(full && score ? { score } : {}),
|
|
54
106
|
},
|
|
55
107
|
null,
|
|
56
108
|
2,
|