ai-search-auditor 0.1.5 → 0.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0](https://github.com/a-bibikov/ai-search-audit/compare/v0.2.0...v0.2.0) (2026-11-03)
4
+
5
+
6
+ ### New checks and features
7
+
8
+ * checklist by section at the end of the report ([c43423c](https://github.com/a-bibikov/ai-search-audit/commit/c43423c6f1c522de056552980c39db7cb411b082))
9
+ * what to do first, coverage in the summary, weight by scale, llms.txt is optional ([f9e6ffb](https://github.com/a-bibikov/ai-search-audit/commit/f9e6ffb74b51c96b2e9639b4f580dbabe7809512))
10
+
11
+
12
+ ### Fixes
13
+
14
+ * checks that cannot run are marked not checked, not passed ([e01eec5](https://github.com/a-bibikov/ai-search-audit/commit/e01eec5674c5f313ffa7b957af6c3520b86e840c))
15
+ * checks that cannot run are marked not checked, not passed ([9281b96](https://github.com/a-bibikov/ai-search-audit/commit/9281b96ad2c6dee10d4a756b11e825fe858fc563))
16
+ * CLI help names the npm package, --version flag ([31887fa](https://github.com/a-bibikov/ai-search-audit/commit/31887fada369eb41024f119b79359e5a98c03126))
17
+
18
+
19
+ ### Documentation
20
+
21
+ * what the audit checks, roadmap, release process ([365965a](https://github.com/a-bibikov/ai-search-audit/commit/365965a7f2f0842a4dba13a0d1b8f1dfa8a8404b))
22
+
23
+
24
+ ### Internal
25
+
26
+ * release 0.1.4 ([2c8056d](https://github.com/a-bibikov/ai-search-audit/commit/2c8056d386588262362e66c5fce78726df9127d8))
27
+
28
+ ## [0.2.0](https://github.com/a-bibikov/ai-search-audit/compare/v0.1.5...v0.2.0) (2026-11-03)
29
+
30
+
31
+ ### New checks and features
32
+
33
+ * what to do first, coverage in the summary, weight by scale, llms.txt is optional ([f9e6ffb](https://github.com/a-bibikov/ai-search-audit/commit/f9e6ffb74b51c96b2e9639b4f580dbabe7809512))
34
+
3
35
  ## [0.1.5](https://github.com/a-bibikov/ai-search-audit/compare/v0.1.4...v0.1.5) (2026-11-03)
4
36
 
5
37
 
package/README.md CHANGED
@@ -53,7 +53,7 @@ Most GEO / AI SEO checkers score a checklist: "is there an FAQ?", "are AI bots n
53
53
  - **D-2** Articles give the answer in the first two paragraphs
54
54
 
55
55
  **E. Machine-readable versions**
56
- - **E-1** `llms.txt` exists and follows the standard
56
+ - **E-1** If there is an `llms.txt`, it follows the standard. It is optional: AI search engines rarely read it today, coding agents and docs tools do
57
57
  - **E-2** Every link in `llms.txt` works and is a published page
58
58
  - **E-3** Each language has its own `llms.txt`
59
59
  - **E-5** `llms-full.txt` carries the full text
@@ -68,9 +68,9 @@ Gaps in the numbering are taken by the checks on the [roadmap](#roadmap).
68
68
 
69
69
  Levels: `error` — AI search cannot read the page or reads it wrong; `warn` — lowers the chance of a citation; `info` — a recommendation.
70
70
 
71
- The report lists the findings with fixes first, then ends with a checklist of every check by group: ✓ OK, ✖ error, ⚠ warning, ℹ note, – not checked. A check that cannot run on a site — articles on a site without articles, `llms.txt` links when there is no `llms.txt` — is marked «not checked» with the reason, never as passed.
71
+ The report opens with what to do first — the three changes worth the most, in plain words and with how many pages they touch — then lists the findings with fixes, and ends with a checklist of every check by group: ✓ OK, ✖ error, ⚠ warning, ℹ note, – not checked. A check that cannot run on a site — articles on a site without articles, `llms.txt` links when there is no `llms.txt` — is marked «not checked» with the reason, never as passed.
72
72
 
73
- **Score:** 100 minus 15 per rule with an error and 5 per rule with a warning. A rule counts once, not per page, so one template bug does not sink a large site. A ≥ 90, B ≥ 80, C ≥ 70, D ≥ 60, F below.
73
+ **Score:** 100 minus 15 for each check with an error and 5 for each check with a warning; a warning that covers at least half of the audited pages costs 10. A check counts once, not per page, so one template bug does not sink a large site. A ≥ 90, B ≥ 80, C ≥ 70, D ≥ 60, F below. The summary says how many checks could run on the site; if fewer than two thirds did, the grade is capped at B.
74
74
 
75
75
  ## Usage
76
76
 
package/dist/i18n/en.d.ts CHANGED
@@ -8,11 +8,17 @@ export declare const en: {
8
8
  summary: (o: {
9
9
  score: number;
10
10
  grade: string;
11
+ checked: number;
12
+ total: number;
11
13
  errors: number;
12
14
  warnings: number;
13
15
  notes: number;
14
16
  pages: number;
15
17
  }) => string;
18
+ capped: (checked: number, total: number) => string;
19
+ priorities: string;
20
+ /** Scale of a problem in the priorities: «on 12 of 20 pages». */
21
+ scale: (n: number, total: number) => string;
16
22
  clean: string;
17
23
  fix: string;
18
24
  /** Label instead of «Fix» when a rule has only notes: nothing is broken, it is advice. */
@@ -43,82 +49,102 @@ export declare const en: {
43
49
  "A-1": {
44
50
  title: string;
45
51
  fix: string;
52
+ impact: string;
46
53
  };
47
54
  "A-2": {
48
55
  title: string;
49
56
  fix: string;
57
+ impact: string;
50
58
  };
51
59
  "A-3": {
52
60
  title: string;
53
61
  fix: string;
62
+ impact: string;
54
63
  };
55
64
  "A-4": {
56
65
  title: string;
57
66
  fix: string;
67
+ impact: string;
58
68
  };
59
69
  "A-5": {
60
70
  title: string;
61
71
  fix: string;
72
+ impact: string;
62
73
  };
63
74
  "B-1": {
64
75
  title: string;
65
76
  fix: string;
77
+ impact: string;
66
78
  };
67
79
  "B-2": {
68
80
  title: string;
69
81
  fix: string;
82
+ impact: string;
70
83
  };
71
84
  "B-3": {
72
85
  title: string;
73
86
  fix: string;
87
+ impact: string;
74
88
  };
75
89
  "C-1": {
76
90
  title: string;
77
91
  fix: string;
92
+ impact: string;
78
93
  };
79
94
  "C-2": {
80
95
  title: string;
81
96
  fix: string;
97
+ impact: string;
82
98
  };
83
99
  "C-4": {
84
100
  title: string;
85
101
  fix: string;
102
+ impact: string;
86
103
  };
87
104
  "C-6": {
88
105
  title: string;
89
106
  fix: string;
107
+ impact: string;
90
108
  };
91
109
  "C-7": {
92
110
  title: string;
93
111
  fix: string;
112
+ impact: string;
94
113
  };
95
114
  "D-2": {
96
115
  title: string;
97
116
  fix: string;
117
+ impact: string;
98
118
  };
99
119
  "E-1": {
100
120
  title: string;
101
121
  fix: string;
122
+ impact: string;
102
123
  };
103
124
  "E-2": {
104
125
  title: string;
105
126
  fix: string;
127
+ impact: string;
106
128
  };
107
129
  "E-3": {
108
130
  title: string;
109
131
  fix: string;
132
+ impact: string;
110
133
  };
111
134
  "E-5": {
112
135
  title: string;
113
136
  fix: string;
137
+ impact: string;
114
138
  };
115
139
  "F-1": {
116
140
  title: string;
117
141
  fix: string;
142
+ impact: string;
118
143
  };
119
144
  "G-1": {
120
145
  title: string;
121
146
  fix: string;
147
+ impact: string;
122
148
  };
123
149
  };
124
150
  /** Finding messages. Quotes («…»), URLs and code come in already formatted. */
@@ -141,6 +167,7 @@ export declare const en: {
141
167
  noId: (type: string, name: string) => string;
142
168
  noSameAs: (type: string, name: string) => string;
143
169
  noJobTitle: (name: string) => string;
170
+ entityNotOnHome: (url: string) => string;
144
171
  authorNoId: (name: string) => string;
145
172
  authorUnknownId: (id: string) => string;
146
173
  articleMissing: (type: string, fields: string) => string;
@@ -150,6 +177,7 @@ export declare const en: {
150
177
  introFirst: (q: string) => string;
151
178
  noLongParagraph: string;
152
179
  notFound: (path: string) => string;
180
+ llmsMissing: string;
153
181
  answers: (path: string, status: string) => string;
154
182
  skipNoArticles: string;
155
183
  skipNoLlms: string;
package/dist/i18n/en.js CHANGED
@@ -5,7 +5,11 @@
5
5
  export const en = {
6
6
  report: {
7
7
  title: (site) => `AI search audit: ${site}`,
8
- summary: (o) => `**${o.score}/100 (${o.grade})** · ${o.errors} errors · ${o.warnings} warnings · ${o.notes} notes · ${o.pages} pages`,
8
+ summary: (o) => `**${o.score}/100 (${o.grade})** · ${o.checked} of ${o.total} checks run · ${o.errors} errors · ${o.warnings} warnings · ${o.notes} notes · ${o.pages} pages`,
9
+ capped: (checked, total) => `Grade capped at B: only ${checked} of ${total} checks could run on this site.`,
10
+ priorities: "What to do first",
11
+ /** Scale of a problem in the priorities: «on 12 of 20 pages». */
12
+ scale: (n, total) => `on ${n} of ${total} pages`,
9
13
  clean: "Nothing to fix: AI search can read, understand and cite these pages.",
10
14
  fix: "Fix",
11
15
  /** Label instead of «Fix» when a rule has only notes: nothing is broken, it is advice. */
@@ -38,82 +42,102 @@ export const en = {
38
42
  "A-1": {
39
43
  title: "robots.txt lets AI search agents in",
40
44
  fix: "Remove the Disallow for these agents (or add `Allow: /` in their group). They fetch pages to answer questions and cite them; training crawlers are a separate choice (A-2).",
45
+ impact: "AI search is not allowed to read the site, so it cannot cite it.",
41
46
  },
42
47
  "A-2": {
43
48
  title: "Training crawlers follow your policy",
44
49
  fix: "Decide whether future models may learn from the site and state it in robots.txt with a group per crawler. Blocking them does not remove you from AI search (A-1).",
50
+ impact: "Whether future models may learn from the site is not decided.",
45
51
  },
46
52
  "A-3": {
47
53
  title: "The server answers AI agents like a browser",
48
54
  fix: "Check the CDN, WAF or bot protection: these agents get an error, a challenge or a different page. Allow them by user agent or verified IP ranges.",
55
+ impact: "AI agents get an error or a check page instead of your content.",
49
56
  },
50
57
  "A-4": {
51
58
  title: "No noindex or noai on public pages",
52
59
  fix: 'Remove `noindex`, `none`, `noai` from `<meta name="robots">` and the `X-Robots-Tag` header on pages that should be cited (keep them on previews and staging).',
60
+ impact: "Pages are marked as closed, so AI search skips them.",
53
61
  },
54
62
  "A-5": {
55
63
  title: "The feed is declared in <head>",
56
64
  fix: 'Add `<link rel="alternate" type="application/rss+xml" href="…">` to pages so crawlers find new content without guessing the address.',
65
+ impact: "New pages are found later than they could be.",
57
66
  },
58
67
  "B-1": {
59
68
  title: "The text is in the HTML, not only in JavaScript",
60
69
  fix: "Render the content on the server (SSR or static generation). Most AI crawlers do not run JavaScript: what is not in the HTML does not exist for them.",
70
+ impact: "ChatGPT, Claude and Perplexity see an empty page: the text appears only after scripts run.",
61
71
  },
62
72
  "B-2": {
63
73
  title: "No invisible characters inside words in machine-readable output",
64
74
  fix: "Strip U+2060, U+200B, U+FEFF and U+00AD from <title>, meta, JSON-LD, feeds and llms.txt: «AI-⟨U+2060⟩native» with a hidden character does not match the query «AI-native». On the page itself typography can keep them, use CSS `white-space: nowrap` where you can.",
75
+ impact: "Hidden characters break words, so the text does not match what people search for.",
65
76
  },
66
77
  "B-3": {
67
78
  title: "Headings do not glue words together",
68
79
  fix: 'Put a space between sibling elements inside the heading (in JSX: `{" "}`). Styles show two lines, but a crawler reads the text nodes joined: «NameTagline».',
80
+ impact: "Headings read as glued words, so AI misreads what the page is about.",
69
81
  },
70
82
  "C-1": {
71
83
  title: "The author or company is a defined entity",
72
84
  fix: "Add JSON-LD Person (or Organization) to the home or about page with a stable `@id` (e.g. https://example.com/#person), `name`, `url`, `sameAs` with your profiles, and `jobTitle` for a person.",
85
+ impact: "AI search cannot tell who stands behind the site, so it trusts it less.",
73
86
  },
74
87
  "C-2": {
75
88
  title: "Articles point at the author entity by @id",
76
89
  fix: 'Set `author: { "@id": "https://example.com/#person" }` (plus name and url if you like) — the same @id as the Person on the home page.',
90
+ impact: "Articles are not tied to their author, so the expertise does not add up to one name.",
77
91
  },
78
92
  "C-4": {
79
93
  title: "Articles have complete Article / BlogPosting markup",
80
94
  fix: "Fill in `headline`, `datePublished`, `author` (required), and `dateModified`, `image`, `inLanguage`.",
95
+ impact: "Articles lack dates or authorship, so AI search treats them as less reliable.",
81
96
  },
82
97
  "C-6": {
83
98
  title: "Every page has a link card: og:title, og:description, og:image",
84
99
  fix: "Add og:image (1200×630) and og:title / og:description to each public page. Chat apps, social networks and AI answers build the link card from them.",
100
+ impact: "Links to the site look bare in chats and AI answers: no title, no picture.",
85
101
  },
86
102
  "C-7": {
87
103
  title: "A visible FAQ is marked up as FAQPage",
88
104
  fix: "If the page really is questions and answers, add FAQPage JSON-LD with the same questions and answers as on the page.",
105
+ impact: "Questions and answers on the page are not marked as such, so they are quoted less often.",
89
106
  },
90
107
  "D-2": {
91
108
  title: "Articles answer in the first two paragraphs",
92
109
  fix: "Open with the answer: what it is, for whom, the main conclusion, in 25+ words. Answer engines quote the first paragraphs most often; an intro like «In this article we will…» wastes them.",
110
+ impact: "The answer is buried below the intro, while AI search quotes the opening.",
93
111
  },
94
112
  "E-1": {
95
- title: "llms.txt exists and follows llmstxt.org",
96
- fix: "Serve /llms.txt as Markdown: `# Site name`, a `> one-line summary`, then `## Sections` with `- [Title](url): note` links to the key pages.",
113
+ title: "llms.txt follows llmstxt.org (optional)",
114
+ fix: "llms.txt is optional: AI search engines rarely read it, coding agents and docs tools do — worth it for documentation, APIs and developer products. If you publish one, serve Markdown: `# Site name`, a `> one-line summary`, then `## Sections` with `- [Title](url): note` links to the key pages.",
115
+ impact: "The published llms.txt cannot be read the way the agents that use it expect.",
97
116
  },
98
117
  "E-2": {
99
118
  title: "Links in llms.txt work and match the sitemap",
100
119
  fix: "Fix or remove broken links; list only published pages. A link that is in llms.txt but not in sitemap.xml is often a draft or a stale URL.",
120
+ impact: "Agents that use llms.txt are sent to broken or unpublished pages.",
101
121
  },
102
122
  "E-3": {
103
123
  title: "Each language has its own llms.txt",
104
124
  fix: "For a multilingual site, serve llms.txt per language (e.g. /llms.txt and /ru/llms.txt) and link them to each other.",
125
+ impact: "Language models get the guide to the site in one language only.",
105
126
  },
106
127
  "E-5": {
107
128
  title: "llms-full.txt carries the full text",
108
129
  fix: "Serve /llms-full.txt with the full text of key pages and articles, so a model can read the site in one request.",
130
+ impact: "A model cannot read the site's content in one request.",
109
131
  },
110
132
  "F-1": {
111
133
  title: "Sitemap lastmod matches the page's dateModified",
112
134
  fix: "Generate lastmod from the same date as JSON-LD dateModified; a mismatch tells crawlers the page changed when it did not, or hides real updates.",
135
+ impact: "Search engines get mixed signals about when pages changed.",
113
136
  },
114
137
  "G-1": {
115
138
  title: "No instructions addressed to AI in the content",
116
139
  fix: "Remove lines that tell AI systems what to say or do. Answer engines treat them as prompt injection and may drop the page or trust the site less. An easter egg for humans can live in humans.txt or the console.",
140
+ impact: "Text addressed to AI looks like a manipulation attempt and lowers trust in the site.",
117
141
  },
118
142
  },
119
143
  /** Finding messages. Quotes («…»), URLs and code come in already formatted. */
@@ -136,6 +160,7 @@ export const en = {
136
160
  noId: (type, name) => `${type} «${name}» has no @id: articles cannot point at it as one entity`,
137
161
  noSameAs: (type, name) => `${type} «${name}» has no sameAs (LinkedIn, GitHub…): models cannot confirm who this is`,
138
162
  noJobTitle: (name) => `Person «${name}» has no jobTitle`,
163
+ entityNotOnHome: (url) => `the home page does not describe the company or author; it is described only on ${url}`,
139
164
  authorNoId: (name) => `author ${name} has no @id`,
140
165
  authorUnknownId: (id) => `author @id ${id} matches no Person or Organization defined on the site`,
141
166
  articleMissing: (type, fields) => `${type} without ${fields}`,
@@ -145,6 +170,7 @@ export const en = {
145
170
  introFirst: (q) => `starts with an intro instead of the answer: ${q}`,
146
171
  noLongParagraph: "no paragraph of 25+ words among the first two",
147
172
  notFound: (path) => `${path} not found`,
173
+ llmsMissing: "no /llms.txt — optional: AI search engines rarely read it; it helps documentation, APIs and coding agents",
148
174
  answers: (path, status) => `${path} answers ${status}, so AI agents cannot read it`,
149
175
  skipNoArticles: "no articles among the audited pages",
150
176
  skipNoLlms: "no llms.txt",
@@ -8,11 +8,16 @@ export declare const LANGS: {
8
8
  summary: (o: {
9
9
  score: number;
10
10
  grade: string;
11
+ checked: number;
12
+ total: number;
11
13
  errors: number;
12
14
  warnings: number;
13
15
  notes: number;
14
16
  pages: number;
15
17
  }) => string;
18
+ capped: (checked: number, total: number) => string;
19
+ priorities: string;
20
+ scale: (n: number, total: number) => string;
16
21
  clean: string;
17
22
  fix: string;
18
23
  recommendation: string;
@@ -41,82 +46,102 @@ export declare const LANGS: {
41
46
  "A-1": {
42
47
  title: string;
43
48
  fix: string;
49
+ impact: string;
44
50
  };
45
51
  "A-2": {
46
52
  title: string;
47
53
  fix: string;
54
+ impact: string;
48
55
  };
49
56
  "A-3": {
50
57
  title: string;
51
58
  fix: string;
59
+ impact: string;
52
60
  };
53
61
  "A-4": {
54
62
  title: string;
55
63
  fix: string;
64
+ impact: string;
56
65
  };
57
66
  "A-5": {
58
67
  title: string;
59
68
  fix: string;
69
+ impact: string;
60
70
  };
61
71
  "B-1": {
62
72
  title: string;
63
73
  fix: string;
74
+ impact: string;
64
75
  };
65
76
  "B-2": {
66
77
  title: string;
67
78
  fix: string;
79
+ impact: string;
68
80
  };
69
81
  "B-3": {
70
82
  title: string;
71
83
  fix: string;
84
+ impact: string;
72
85
  };
73
86
  "C-1": {
74
87
  title: string;
75
88
  fix: string;
89
+ impact: string;
76
90
  };
77
91
  "C-2": {
78
92
  title: string;
79
93
  fix: string;
94
+ impact: string;
80
95
  };
81
96
  "C-4": {
82
97
  title: string;
83
98
  fix: string;
99
+ impact: string;
84
100
  };
85
101
  "C-6": {
86
102
  title: string;
87
103
  fix: string;
104
+ impact: string;
88
105
  };
89
106
  "C-7": {
90
107
  title: string;
91
108
  fix: string;
109
+ impact: string;
92
110
  };
93
111
  "D-2": {
94
112
  title: string;
95
113
  fix: string;
114
+ impact: string;
96
115
  };
97
116
  "E-1": {
98
117
  title: string;
99
118
  fix: string;
119
+ impact: string;
100
120
  };
101
121
  "E-2": {
102
122
  title: string;
103
123
  fix: string;
124
+ impact: string;
104
125
  };
105
126
  "E-3": {
106
127
  title: string;
107
128
  fix: string;
129
+ impact: string;
108
130
  };
109
131
  "E-5": {
110
132
  title: string;
111
133
  fix: string;
134
+ impact: string;
112
135
  };
113
136
  "F-1": {
114
137
  title: string;
115
138
  fix: string;
139
+ impact: string;
116
140
  };
117
141
  "G-1": {
118
142
  title: string;
119
143
  fix: string;
144
+ impact: string;
120
145
  };
121
146
  };
122
147
  m: {
@@ -138,6 +163,7 @@ export declare const LANGS: {
138
163
  noId: (type: string, name: string) => string;
139
164
  noSameAs: (type: string, name: string) => string;
140
165
  noJobTitle: (name: string) => string;
166
+ entityNotOnHome: (url: string) => string;
141
167
  authorNoId: (name: string) => string;
142
168
  authorUnknownId: (id: string) => string;
143
169
  articleMissing: (type: string, fields: string) => string;
@@ -147,6 +173,7 @@ export declare const LANGS: {
147
173
  introFirst: (q: string) => string;
148
174
  noLongParagraph: string;
149
175
  notFound: (path: string) => string;
176
+ llmsMissing: string;
150
177
  answers: (path: string, status: string) => string;
151
178
  skipNoArticles: string;
152
179
  skipNoLlms: string;
@@ -173,11 +200,16 @@ export declare const LANGS: {
173
200
  summary: (o: {
174
201
  score: number;
175
202
  grade: string;
203
+ checked: number;
204
+ total: number;
176
205
  errors: number;
177
206
  warnings: number;
178
207
  notes: number;
179
208
  pages: number;
180
209
  }) => string;
210
+ capped: (checked: number, total: number) => string;
211
+ priorities: string;
212
+ scale: (n: number, total: number) => string;
181
213
  clean: string;
182
214
  fix: string;
183
215
  recommendation: string;
@@ -206,82 +238,102 @@ export declare const LANGS: {
206
238
  "A-1": {
207
239
  title: string;
208
240
  fix: string;
241
+ impact: string;
209
242
  };
210
243
  "A-2": {
211
244
  title: string;
212
245
  fix: string;
246
+ impact: string;
213
247
  };
214
248
  "A-3": {
215
249
  title: string;
216
250
  fix: string;
251
+ impact: string;
217
252
  };
218
253
  "A-4": {
219
254
  title: string;
220
255
  fix: string;
256
+ impact: string;
221
257
  };
222
258
  "A-5": {
223
259
  title: string;
224
260
  fix: string;
261
+ impact: string;
225
262
  };
226
263
  "B-1": {
227
264
  title: string;
228
265
  fix: string;
266
+ impact: string;
229
267
  };
230
268
  "B-2": {
231
269
  title: string;
232
270
  fix: string;
271
+ impact: string;
233
272
  };
234
273
  "B-3": {
235
274
  title: string;
236
275
  fix: string;
276
+ impact: string;
237
277
  };
238
278
  "C-1": {
239
279
  title: string;
240
280
  fix: string;
281
+ impact: string;
241
282
  };
242
283
  "C-2": {
243
284
  title: string;
244
285
  fix: string;
286
+ impact: string;
245
287
  };
246
288
  "C-4": {
247
289
  title: string;
248
290
  fix: string;
291
+ impact: string;
249
292
  };
250
293
  "C-6": {
251
294
  title: string;
252
295
  fix: string;
296
+ impact: string;
253
297
  };
254
298
  "C-7": {
255
299
  title: string;
256
300
  fix: string;
301
+ impact: string;
257
302
  };
258
303
  "D-2": {
259
304
  title: string;
260
305
  fix: string;
306
+ impact: string;
261
307
  };
262
308
  "E-1": {
263
309
  title: string;
264
310
  fix: string;
311
+ impact: string;
265
312
  };
266
313
  "E-2": {
267
314
  title: string;
268
315
  fix: string;
316
+ impact: string;
269
317
  };
270
318
  "E-3": {
271
319
  title: string;
272
320
  fix: string;
321
+ impact: string;
273
322
  };
274
323
  "E-5": {
275
324
  title: string;
276
325
  fix: string;
326
+ impact: string;
277
327
  };
278
328
  "F-1": {
279
329
  title: string;
280
330
  fix: string;
331
+ impact: string;
281
332
  };
282
333
  "G-1": {
283
334
  title: string;
284
335
  fix: string;
336
+ impact: string;
285
337
  };
286
338
  };
287
339
  m: {
@@ -303,6 +355,7 @@ export declare const LANGS: {
303
355
  noId: (type: string, name: string) => string;
304
356
  noSameAs: (type: string, name: string) => string;
305
357
  noJobTitle: (name: string) => string;
358
+ entityNotOnHome: (url: string) => string;
306
359
  authorNoId: (name: string) => string;
307
360
  authorUnknownId: (id: string) => string;
308
361
  articleMissing: (type: string, fields: string) => string;
@@ -312,6 +365,7 @@ export declare const LANGS: {
312
365
  introFirst: (q: string) => string;
313
366
  noLongParagraph: string;
314
367
  notFound: (path: string) => string;
368
+ llmsMissing: string;
315
369
  answers: (path: string, status: string) => string;
316
370
  skipNoArticles: string;
317
371
  skipNoLlms: string;
package/dist/i18n/ru.js CHANGED
@@ -2,7 +2,10 @@
2
2
  export const ru = {
3
3
  report: {
4
4
  title: (site) => `Проверка для AI-поиска: ${site}`,
5
- summary: (o) => `**${o.score}/100 (${o.grade})** · ошибок: ${o.errors} · предупреждений: ${o.warnings} · заметок: ${o.notes} · страниц: ${o.pages}`,
5
+ summary: (o) => `**${o.score}/100 (${o.grade})** · проверено: ${o.checked} из ${o.total} · ошибок: ${o.errors} · предупреждений: ${o.warnings} · заметок: ${o.notes} · страниц: ${o.pages}`,
6
+ capped: (checked, total) => `Оценка ограничена B: на этом сайте удалось выполнить только ${checked} проверок из ${total}.`,
7
+ priorities: "Что сделать в первую очередь",
8
+ scale: (n, total) => `страниц: ${n} из ${total}`,
6
9
  clean: "Исправлять нечего: AI-поиск может прочитать, понять и процитировать эти страницы.",
7
10
  fix: "Как исправить",
8
11
  recommendation: "Рекомендация",
@@ -27,82 +30,102 @@ export const ru = {
27
30
  "A-1": {
28
31
  title: "robots.txt пускает поисковых AI-агентов",
29
32
  fix: "Уберите Disallow для этих агентов (или добавьте `Allow: /` в их группу). Они загружают страницы, чтобы ответить на вопрос и сослаться на источник; боты обучения — отдельное решение (A-2).",
33
+ impact: "AI-поиску запрещено читать сайт — он не может на вас ссылаться.",
30
34
  },
31
35
  "A-2": {
32
36
  title: "Боты обучения следуют вашей политике",
33
37
  fix: "Решите, могут ли будущие модели учиться на сайте, и укажите это в robots.txt отдельной группой для каждого бота. Запрет для них не убирает сайт из AI-поиска (A-1).",
38
+ impact: "Не решено, могут ли будущие модели учиться на вашем сайте.",
34
39
  },
35
40
  "A-3": {
36
41
  title: "Сервер отвечает AI-агентам так же, как браузеру",
37
42
  fix: "Проверьте CDN, WAF или защиту от ботов: эти агенты получают ошибку, проверку или другую страницу. Разрешите их по User-Agent или по подтверждённым диапазонам IP.",
43
+ impact: "AI-агенты получают ошибку или проверку вместо вашего контента.",
38
44
  },
39
45
  "A-4": {
40
46
  title: "На публичных страницах нет noindex и noai",
41
47
  fix: 'Уберите `noindex`, `none`, `noai` из `<meta name="robots">` и заголовка `X-Robots-Tag` на страницах, которые должны цитироваться (на превью и staging их оставьте).',
48
+ impact: "Страницы помечены как закрытые — AI-поиск их пропускает.",
42
49
  },
43
50
  "A-5": {
44
51
  title: "Лента объявлена в <head>",
45
52
  fix: 'Добавьте на страницы `<link rel="alternate" type="application/rss+xml" href="…">`, чтобы краулеры находили новое без угадывания адреса.',
53
+ impact: "Новые страницы находят позже, чем могли бы.",
46
54
  },
47
55
  "B-1": {
48
56
  title: "Текст есть в HTML, а не только в JavaScript",
49
57
  fix: "Рендерите контент на сервере (SSR или статическая генерация). Большинство AI-краулеров не выполняют JavaScript: чего нет в HTML, того для них не существует.",
58
+ impact: "ChatGPT, Claude и Perplexity видят пустую страницу: текст появляется только после скриптов.",
50
59
  },
51
60
  "B-2": {
52
61
  title: "В машиночитаемых версиях нет невидимых символов внутри слов",
53
62
  fix: "Уберите U+2060, U+200B, U+FEFF и U+00AD из <title>, мета-тегов, JSON-LD, лент и llms.txt: «AI-⟨U+2060⟩native» со скрытым символом не совпадает с запросом «AI-native». На самой странице типограф может их оставлять, но где можно, используйте CSS `white-space: nowrap`.",
63
+ impact: "Скрытые символы рвут слова — текст не совпадает с тем, что ищут люди.",
54
64
  },
55
65
  "B-3": {
56
66
  title: "Заголовки не склеивают слова",
57
67
  fix: 'Поставьте пробел между соседними элементами внутри заголовка (в JSX: `{" "}`). Стили показывают две строки, а краулер читает текстовые узлы подряд: «ИмяСлоган».',
68
+ impact: "Заголовки читаются слипшимися — AI неверно понимает, о чём страница.",
58
69
  },
59
70
  "C-1": {
60
71
  title: "Автор или компания описаны как сущность",
61
72
  fix: "Добавьте на главную или страницу «О себе» JSON-LD Person (или Organization) с постоянным `@id` (например, https://example.com/#person), `name`, `url`, `sameAs` со ссылками на профили и `jobTitle` для человека.",
73
+ impact: "AI-поиск не может понять, кто стоит за сайтом, и меньше ему доверяет.",
62
74
  },
63
75
  "C-2": {
64
76
  title: "Статьи ссылаются на автора через @id",
65
77
  fix: 'Укажите `author: { "@id": "https://example.com/#person" }` (name и url можно оставить) — тот же @id, что у Person на главной.',
78
+ impact: "Статьи не привязаны к автору — экспертиза не складывается в одно имя.",
66
79
  },
67
80
  "C-4": {
68
81
  title: "У статей полная разметка Article / BlogPosting",
69
82
  fix: "Заполните `headline`, `datePublished`, `author` (обязательно), а также `dateModified`, `image`, `inLanguage`.",
83
+ impact: "У статей нет дат или авторства — AI-поиск считает их менее надёжными.",
70
84
  },
71
85
  "C-6": {
72
86
  title: "У каждой страницы есть карточка ссылки: og:title, og:description, og:image",
73
87
  fix: "Добавьте og:image (1200×630) и og:title / og:description на каждую публичную страницу. По ним мессенджеры, соцсети и AI-ответы строят карточку ссылки.",
88
+ impact: "Ссылки на сайт в мессенджерах и AI-ответах выглядят пустыми: без заголовка и картинки.",
74
89
  },
75
90
  "C-7": {
76
91
  title: "Видимый FAQ размечен как FAQPage",
77
92
  fix: "Если страница действительно состоит из вопросов и ответов, добавьте JSON-LD FAQPage с теми же вопросами и ответами, что на странице.",
93
+ impact: "Вопросы и ответы на странице не размечены — их реже берут в ответы.",
78
94
  },
79
95
  "D-2": {
80
96
  title: "Статьи отвечают в первых двух абзацах",
81
97
  fix: "Начинайте с ответа: что это, для кого, главный вывод — от 25 слов. AI-поиск чаще всего цитирует первые абзацы; вступление вроде «В этой статье мы расскажем…» тратит их впустую.",
98
+ impact: "Ответ спрятан за вступлением, а AI-поиск цитирует начало.",
82
99
  },
83
100
  "E-1": {
84
- title: "llms.txt есть и соответствует llmstxt.org",
85
- fix: "Отдавайте /llms.txt в Markdown: `# Название сайта`, строка `> краткое описание`, затем разделы `## …` со ссылками `- [Заголовок](url): пояснение` на ключевые страницы.",
101
+ title: "llms.txt соответствует llmstxt.org (необязательно)",
102
+ fix: "llms.txt необязателен: AI-поиск его почти не читает, а агенты для разработки и инструменты документации читают — он нужен сайтам с документацией, API и продуктами для разработчиков. Если публикуете, отдавайте Markdown: `# Название сайта`, строка `> краткое описание`, затем разделы `## …` со ссылками `- [Заголовок](url): пояснение` на ключевые страницы.",
103
+ impact: "Опубликованный llms.txt не читается так, как ожидают агенты, которые им пользуются.",
86
104
  },
87
105
  "E-2": {
88
106
  title: "Ссылки в llms.txt работают и совпадают с sitemap",
89
107
  fix: "Исправьте или уберите битые ссылки; оставляйте только опубликованные страницы. Ссылка, которая есть в llms.txt, но не в sitemap.xml, — часто черновик или устаревший адрес.",
108
+ impact: "Агентов, которые пользуются llms.txt, отправляют на битые или неопубликованные страницы.",
90
109
  },
91
110
  "E-3": {
92
111
  title: "У каждого языка свой llms.txt",
93
112
  fix: "Для многоязычного сайта отдавайте llms.txt на каждом языке (например, /llms.txt и /ru/llms.txt) и связывайте их ссылками друг на друга.",
113
+ impact: "Модели получают путеводитель по сайту только на одном языке.",
94
114
  },
95
115
  "E-5": {
96
116
  title: "llms-full.txt содержит полный текст",
97
117
  fix: "Отдавайте /llms-full.txt с полным текстом ключевых страниц и статей, чтобы модель могла прочитать сайт за один запрос.",
118
+ impact: "Модель не может прочитать содержимое сайта за один запрос.",
98
119
  },
99
120
  "F-1": {
100
121
  title: "lastmod в sitemap совпадает с dateModified страницы",
101
122
  fix: "Берите lastmod из той же даты, что и dateModified в JSON-LD: расхождение сообщает краулерам об изменениях, которых не было, или скрывает настоящие обновления.",
123
+ impact: "Поисковики получают противоречивые сигналы о свежести страниц.",
102
124
  },
103
125
  "G-1": {
104
126
  title: "В контенте нет указаний, обращённых к AI",
105
127
  fix: "Уберите строки, которые говорят AI-системам, что сказать или сделать. AI-поиск считает их prompt injection и может отбросить страницу или меньше доверять сайту. Пасхалка для людей может жить в humans.txt или в консоли.",
128
+ impact: "Обращения к AI выглядят как попытка манипуляции и снижают доверие к сайту.",
106
129
  },
107
130
  },
108
131
  m: {
@@ -124,6 +147,7 @@ export const ru = {
124
147
  noId: (type, name) => `у ${type} «${name}» нет @id: статьи не могут сослаться на него как на одну сущность`,
125
148
  noSameAs: (type, name) => `у ${type} «${name}» нет sameAs (LinkedIn, GitHub…): модели не могут подтвердить, кто это`,
126
149
  noJobTitle: (name) => `у Person «${name}» нет jobTitle`,
150
+ entityNotOnHome: (url) => `на главной нет описания компании или автора — оно есть только на ${url}`,
127
151
  authorNoId: (name) => `у автора ${name} нет @id`,
128
152
  authorUnknownId: (id) => `@id автора ${id} не совпадает ни с одним Person или Organization на сайте`,
129
153
  articleMissing: (type, fields) => `в ${type} нет ${fields}`,
@@ -133,6 +157,7 @@ export const ru = {
133
157
  introFirst: (q) => `начинается со вступления, а не с ответа: ${q}`,
134
158
  noLongParagraph: "среди первых двух абзацев нет абзаца от 25 слов",
135
159
  notFound: (path) => `${path} не найден`,
160
+ llmsMissing: "нет /llms.txt — это необязательно: AI-поиск его почти не читает, он полезен документации, API и агентам для разработки",
136
161
  answers: (path, status) => `${path} отвечает ${status}, AI-агенты не могут его прочитать`,
137
162
  skipNoArticles: "среди проверенных страниц нет статей",
138
163
  skipNoLlms: "нет llms.txt",
package/dist/index.d.ts CHANGED
@@ -21,6 +21,7 @@ export interface Report extends Score {
21
21
  level: Level;
22
22
  title: string;
23
23
  fix: string;
24
+ impact: string;
24
25
  skipped?: string;
25
26
  }[];
26
27
  }
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ export async function audit(target, opts = {}) {
19
19
  lang: config.lang,
20
20
  checkedAt: new Date().toISOString(),
21
21
  pages: site.pages.map((p) => p.fetched.publicUrl),
22
- ...scoreOf(findings),
22
+ ...scoreOf(findings, { pages: site.pages.length, checked: active.filter((r) => !skipped.get(r.id)).length, total: active.length }),
23
23
  findings,
24
24
  rules: active.map(({ id, group, level }) => ({ id, group, level, ...t.rules[id], ...(skipped.get(id) ? { skipped: skipped.get(id) } : {}) })),
25
25
  };
package/dist/report.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { messages } from "./i18n/index.js";
2
+ import { pagesOf, penaltyOf } from "./score.js";
2
3
  const ICON = { error: "✖", warn: "⚠", info: "ℹ" };
3
4
  const ORDER = { error: 0, warn: 1, info: 2 };
4
5
  const MAX_PER_RULE = 8;
6
+ const MAX_PRIORITIES = 3;
5
7
  /** Markdown report for a terminal, a PR summary or a file, in the report's language. */
6
8
  export function toMarkdown(r) {
7
9
  const t = messages(r.lang).report;
@@ -9,12 +11,26 @@ export function toMarkdown(r) {
9
11
  const lines = [
10
12
  `# ${t.title(r.site)}`,
11
13
  "",
12
- t.summary({ score: r.score, grade: r.grade, errors: r.counts.error, warnings: r.counts.warn, notes: r.counts.info, pages: r.pages.length }),
14
+ t.summary({ score: r.score, grade: r.grade, checked: r.checked, total: r.total, errors: r.counts.error, warnings: r.counts.warn, notes: r.counts.info, pages: r.pages.length }),
13
15
  "",
14
16
  ];
17
+ if (r.capped)
18
+ lines.push(t.capped(r.checked, r.total), "");
15
19
  const byRule = new Map();
16
20
  for (const f of r.findings)
17
21
  byRule.set(f.rule, [...(byRule.get(f.rule) ?? []), f]);
22
+ // The few changes worth the most points, in plain words: what it costs the business, then the check.
23
+ const top = r.rules
24
+ .map((rule) => ({ rule, found: byRule.get(rule.id) ?? [] }))
25
+ .map((x) => ({ ...x, cost: penaltyOf(x.found, r.pages.length), pages: pagesOf(x.found) }))
26
+ .filter((x) => x.cost > 0)
27
+ .sort((a, b) => b.cost - a.cost || b.pages - a.pages || a.rule.id.localeCompare(b.rule.id, "en", { numeric: true }))
28
+ .slice(0, MAX_PRIORITIES);
29
+ if (top.length) {
30
+ lines.push(`## ${t.priorities}`, "");
31
+ top.forEach(({ rule, pages }, i) => lines.push(`${i + 1}. **${rule.impact}** ${rule.id}${pages ? `, ${t.scale(pages, r.pages.length)}` : ""}`));
32
+ lines.push("");
33
+ }
18
34
  const failed = r.rules
19
35
  .filter((rule) => byRule.has(rule.id))
20
36
  .map((rule) => ({ rule, findings: byRule.get(rule.id), worst: byRule.get(rule.id).reduce((w, f) => (ORDER[f.level] < ORDER[w] ? f.level : w), "info") }))
@@ -43,9 +43,11 @@ export const contentRules = [
43
43
  level: "warn",
44
44
  run(site, _config, t) {
45
45
  const { out, add } = finder(this);
46
+ // No llms.txt is a note, not a warning: AI search engines rarely read it (2026 crawler logs), coding agents and
47
+ // docs tools do. A published but broken file is a warning: whoever uses it gets nothing.
46
48
  if (!site.llms) {
47
49
  const a = site.llmsAttempt;
48
- add("site", a.status === 404 || a.status === 410 ? t.notFound("/llms.txt") : t.answers("/llms.txt", a.error ?? `HTTP ${a.status}`));
50
+ add("site", a.status === 404 || a.status === 410 ? t.llmsMissing : t.answers("/llms.txt", a.error ?? `HTTP ${a.status}`), "info");
49
51
  return out;
50
52
  }
51
53
  const body = site.llms.body;
@@ -66,7 +68,7 @@ export const contentRules = [
66
68
  {
67
69
  id: "E-2",
68
70
  group: "E",
69
- level: "error",
71
+ level: "warn",
70
72
  applies(site, t) {
71
73
  return llmsReadable(site) ? null : t.skipNoLlms;
72
74
  },
@@ -77,7 +79,7 @@ export const contentRules = [
77
79
  if (status !== 200)
78
80
  add(href, t.brokenLink(status ? String(status) : t.nothing));
79
81
  else if (site.sitemap && /\/[^./]*$/.test(new URL(href).pathname) && !inSitemap.has(href.replace(/\/$/, "")))
80
- add(href, t.notInSitemap, "warn");
82
+ add(href, t.notInSitemap, "info");
81
83
  }
82
84
  return out;
83
85
  },
@@ -27,6 +27,10 @@ export const entityRules = [
27
27
  add("site", t.noEntity);
28
28
  return out;
29
29
  }
30
+ // The home page is where answer engines look first for who runs the site.
31
+ const home = site.pages.find((p) => p.fetched.publicUrl.replace(/\/$/, "") === site.base);
32
+ if (home && !defined.some(({ page }) => page === home))
33
+ add(home.fetched.publicUrl, t.entityNotOnHome(defined[0].page.fetched.publicUrl), "warn");
30
34
  const seen = new Set();
31
35
  for (const { n, page } of defined) {
32
36
  const key = str(n["@id"]) || str(n.url) || str(n.name);
package/dist/score.d.ts CHANGED
@@ -3,9 +3,27 @@ export interface Score {
3
3
  score: number;
4
4
  grade: "A" | "B" | "C" | "D" | "F";
5
5
  counts: Record<Level, number>;
6
+ /** Checks that ran on this site and all active checks: «14 of 20». */
7
+ checked: number;
8
+ total: number;
9
+ /** The grade was lowered to B because too few checks could run. */
10
+ capped: boolean;
6
11
  }
12
+ export interface ScoreContext {
13
+ /** Audited pages, to weigh how widespread a problem is. */
14
+ pages?: number;
15
+ checked?: number;
16
+ total?: number;
17
+ }
18
+ /**
19
+ * Pages a rule's worst findings are about: notes next to a warning do not make the warning wider,
20
+ * and site-wide findings are not counted as pages.
21
+ */
22
+ export declare const pagesOf: (findings: Finding[]) => number;
23
+ /** What one rule costs: its worst level, and a warning on most pages costs more than a warning on one. */
24
+ export declare function penaltyOf(findings: Finding[], pages?: number): number;
7
25
  /**
8
- * 100 minus a penalty per rule, not per page: a rule counts once at its worst level, so a large site with one
9
- * template bug is not punished 500 times.
26
+ * 100 minus a penalty per rule, not per page: a rule counts once, so a large site with one template bug is not
27
+ * punished 500 times — but a warning that covers most pages counts double.
10
28
  */
11
- export declare function scoreOf(findings: Finding[]): Score;
29
+ export declare function scoreOf(findings: Finding[], ctx?: ScoreContext): Score;
package/dist/score.js CHANGED
@@ -1,19 +1,45 @@
1
1
  const PENALTY = { error: 15, warn: 5, info: 0 };
2
+ /** A warning on at least half of the audited pages is a site-wide problem: it costs more than a single slip. */
3
+ const WIDE_WARN = 10;
4
+ const WIDE_SHARE = 0.5;
5
+ /** Below this share of checks that could run, the site is not fully audited and cannot get an A. */
6
+ const MIN_COVERAGE = 2 / 3;
7
+ const worstOf = (findings) => findings.reduce((w, f) => (PENALTY[f.level] > PENALTY[w] ? f.level : w), "info");
2
8
  /**
3
- * 100 minus a penalty per rule, not per page: a rule counts once at its worst level, so a large site with one
4
- * template bug is not punished 500 times.
9
+ * Pages a rule's worst findings are about: notes next to a warning do not make the warning wider,
10
+ * and site-wide findings are not counted as pages.
5
11
  */
6
- export function scoreOf(findings) {
7
- const worst = new Map();
8
- for (const f of findings) {
9
- const prev = worst.get(f.rule);
10
- if (!prev || PENALTY[f.level] > PENALTY[prev])
11
- worst.set(f.rule, f.level);
12
- }
13
- const score = Math.max(0, 100 - [...worst.values()].reduce((s, l) => s + PENALTY[l], 0));
14
- const grade = score >= 90 ? "A" : score >= 80 ? "B" : score >= 70 ? "C" : score >= 60 ? "D" : "F";
12
+ export const pagesOf = (findings) => {
13
+ const worst = worstOf(findings);
14
+ return new Set(findings.filter((f) => f.level === worst && f.where !== "site").map((f) => f.where)).size;
15
+ };
16
+ /** What one rule costs: its worst level, and a warning on most pages costs more than a warning on one. */
17
+ export function penaltyOf(findings, pages = 0) {
18
+ if (!findings.length)
19
+ return 0;
20
+ const worst = worstOf(findings);
21
+ if (worst === "warn" && pages > 0 && pagesOf(findings) / pages >= WIDE_SHARE)
22
+ return WIDE_WARN;
23
+ return PENALTY[worst];
24
+ }
25
+ /**
26
+ * 100 minus a penalty per rule, not per page: a rule counts once, so a large site with one template bug is not
27
+ * punished 500 times — but a warning that covers most pages counts double.
28
+ */
29
+ export function scoreOf(findings, ctx = {}) {
30
+ const byRule = new Map();
31
+ for (const f of findings)
32
+ byRule.set(f.rule, [...(byRule.get(f.rule) ?? []), f]);
33
+ const penalty = [...byRule.values()].reduce((s, list) => s + penaltyOf(list, ctx.pages ?? 0), 0);
34
+ const score = Math.max(0, 100 - penalty);
35
+ let grade = score >= 90 ? "A" : score >= 80 ? "B" : score >= 70 ? "C" : score >= 60 ? "D" : "F";
36
+ const checked = ctx.checked ?? 0;
37
+ const total = ctx.total ?? 0;
38
+ const capped = grade === "A" && total > 0 && checked / total < MIN_COVERAGE;
39
+ if (capped)
40
+ grade = "B";
15
41
  const counts = { error: 0, warn: 0, info: 0 };
16
42
  for (const f of findings)
17
43
  counts[f.level]++;
18
- return { score, grade, counts };
44
+ return { score, grade, counts, checked, total, capped };
19
45
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-search-auditor",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "description": "Checks whether AI search (ChatGPT, Claude, Perplexity, Google AI Overviews) can read, understand and cite your site: robots.txt for AI agents, text without JavaScript, JSON-LD authorship, llms.txt, freshness.",
5
5
  "keywords": [
6
6
  "ai search audit",