@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 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 findings as a spreadsheet, one row each |
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 — preview vs production |
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
- { tool: 'seo-audit', date: runs[0]?.meta.date, sites: runs.map((r) => ({ ...r.meta, findings: r.findings })) },
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.33.0",
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', '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,
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
- const key = (f) => `${f.id}\u0000${f.url ?? ''}`;
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
- export function diff(previous, current) {
12
- const before = new Map((previous.findings ?? []).map((f) => [key(f), f]));
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 { added, fixed, unchanged, previousDate: previous.meta?.date ?? 'the baseline' };
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,