@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 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 findings as a spreadsheet, one row each |
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 — preview vs production |
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
- { tool: 'seo-audit', date: runs[0]?.meta.date, sites: runs.map((r) => ({ ...r.meta, findings: r.findings })) },
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.33.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', 'llms-missing': '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,