ai-search-auditor 0.1.4 → 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,45 @@
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
+
35
+ ## [0.1.5](https://github.com/a-bibikov/ai-search-audit/compare/v0.1.4...v0.1.5) (2026-11-03)
36
+
37
+
38
+ ### Fixes
39
+
40
+ * checks that cannot run are marked not checked, not passed ([e01eec5](https://github.com/a-bibikov/ai-search-audit/commit/e01eec5674c5f313ffa7b957af6c3520b86e840c))
41
+ * checks that cannot run are marked not checked, not passed ([9281b96](https://github.com/a-bibikov/ai-search-audit/commit/9281b96ad2c6dee10d4a756b11e825fe858fc563))
42
+
3
43
  ## [0.1.4](https://github.com/a-bibikov/ai-search-audit/compare/v0.1.3...v0.1.4) (2026-11-03)
4
44
 
5
45
 
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.
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/crawl.d.ts CHANGED
@@ -9,6 +9,9 @@ export interface CrawlOptions {
9
9
  /**
10
10
  * Picks pages so that every template is seen: URLs grouped by their path with the last segment masked
11
11
  * (/blog/a and /blog/b are one group), then taken round-robin, home page first.
12
+ * Sites that repeat one page under many prefixes (/moscow/prices, /kazan/prices…) would fill the sample with copies,
13
+ * so a page whose path after the first segment was already picked waits until every other page had its turn.
14
+ * Language prefixes (/ru/, /en-us/) are not treated as such copies: other languages are worth auditing.
12
15
  */
13
16
  export declare function pickPages(home: string, urls: string[], max: number): string[];
14
17
  export declare function crawl(targetInput: string, baseInput: string | undefined, opts: CrawlOptions): Promise<Site>;
package/dist/crawl.js CHANGED
@@ -7,32 +7,52 @@ const origin = (url) => new URL(url).origin;
7
7
  /**
8
8
  * Picks pages so that every template is seen: URLs grouped by their path with the last segment masked
9
9
  * (/blog/a and /blog/b are one group), then taken round-robin, home page first.
10
+ * Sites that repeat one page under many prefixes (/moscow/prices, /kazan/prices…) would fill the sample with copies,
11
+ * so a page whose path after the first segment was already picked waits until every other page had its turn.
12
+ * Language prefixes (/ru/, /en-us/) are not treated as such copies: other languages are worth auditing.
10
13
  */
11
14
  export function pickPages(home, urls, max) {
12
15
  // https://site.com and https://site.com/ are the same page: compare without the trailing slash.
13
16
  const norm = (u) => u.replace(/\/$/, "");
17
+ const segments = (u) => new URL(u).pathname.split("/").filter(Boolean);
18
+ const tailOf = (u) => {
19
+ const segs = segments(u);
20
+ return segs.length >= 2 && !/^[a-z]{2}(-[a-z]{2,4})?$/i.test(segs[0]) ? segs.slice(1).join("/") : null;
21
+ };
14
22
  const seen = new Set([norm(home)]);
15
23
  const groups = new Map();
16
24
  for (const url of urls) {
17
25
  if (seen.has(norm(url)))
18
26
  continue;
19
27
  seen.add(norm(url));
20
- const path = new URL(url).pathname.replace(/\/$/, "");
21
- const segs = path.split("/").filter(Boolean);
28
+ const segs = segments(url);
22
29
  const key = segs.length <= 1 ? `/${segs.join("/")}` : `/${segs.slice(0, -1).join("/")}/*`;
23
30
  if (!groups.has(key))
24
31
  groups.set(key, []);
25
32
  groups.get(key).push(url);
26
33
  }
27
34
  const picked = [home];
35
+ const tails = new Set();
36
+ const later = [];
28
37
  const lists = [...groups.values()];
29
38
  for (let round = 0; picked.length < max && lists.some((l) => l.length > round); round++) {
30
39
  for (const list of lists) {
31
40
  const url = list[round];
32
- if (url && !picked.includes(url) && picked.length < max)
33
- picked.push(url);
41
+ if (!url || picked.length >= max)
42
+ continue;
43
+ const tail = tailOf(url);
44
+ if (tail && tails.has(tail)) {
45
+ later.push(url);
46
+ continue;
47
+ }
48
+ if (tail)
49
+ tails.add(tail);
50
+ picked.push(url);
34
51
  }
35
52
  }
53
+ for (const url of later)
54
+ if (picked.length < max)
55
+ picked.push(url);
36
56
  return picked;
37
57
  }
38
58
  const isHtml = (f) => f.status === 200 && /html/i.test(f.headers.get("content-type") ?? "html");
@@ -91,6 +111,6 @@ export async function crawl(targetInput, baseInput, opts) {
91
111
  const probes = await pool(["/rss.xml", "/feed.xml", "/feed", "/atom.xml", "/index.xml"], conc, (p) => fetchUrl(`${target}${p}`, o, { accept: "application/rss+xml,application/atom+xml,application/xml" }));
92
112
  undeclaredFeed = probes.find((f) => f.status === 200 && /<(rss|feed)\b/i.test(f.body.slice(0, 2000)))?.publicUrl ?? null;
93
113
  }
94
- return { target, base, robots, sitemap, pages, llms: llms.status === 200 ? llms : null, llmsFull: llmsFull.status === 200 ? llmsFull : null, feeds, asAgents, llmsLinks, undeclaredFeed };
114
+ return { target, base, robots, sitemap, pages, llms: llms.status === 200 ? llms : null, llmsAttempt: llms, llmsFull: llmsFull.status === 200 ? llmsFull : null, feeds, asAgents, llmsLinks, undeclaredFeed };
95
115
  }
96
116
  const p0 = (pages, base) => pages[0]?.fetched.publicUrl ?? base;
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. */
@@ -20,6 +26,7 @@ export declare const en: {
20
26
  more: (n: number) => string;
21
27
  checklist: string;
22
28
  legend: string;
29
+ notChecked: string;
23
30
  /** Findings of one rule in the checklist: «2 errors, 1 warning». */
24
31
  count: (o: {
25
32
  errors: number;
@@ -42,82 +49,102 @@ export declare const en: {
42
49
  "A-1": {
43
50
  title: string;
44
51
  fix: string;
52
+ impact: string;
45
53
  };
46
54
  "A-2": {
47
55
  title: string;
48
56
  fix: string;
57
+ impact: string;
49
58
  };
50
59
  "A-3": {
51
60
  title: string;
52
61
  fix: string;
62
+ impact: string;
53
63
  };
54
64
  "A-4": {
55
65
  title: string;
56
66
  fix: string;
67
+ impact: string;
57
68
  };
58
69
  "A-5": {
59
70
  title: string;
60
71
  fix: string;
72
+ impact: string;
61
73
  };
62
74
  "B-1": {
63
75
  title: string;
64
76
  fix: string;
77
+ impact: string;
65
78
  };
66
79
  "B-2": {
67
80
  title: string;
68
81
  fix: string;
82
+ impact: string;
69
83
  };
70
84
  "B-3": {
71
85
  title: string;
72
86
  fix: string;
87
+ impact: string;
73
88
  };
74
89
  "C-1": {
75
90
  title: string;
76
91
  fix: string;
92
+ impact: string;
77
93
  };
78
94
  "C-2": {
79
95
  title: string;
80
96
  fix: string;
97
+ impact: string;
81
98
  };
82
99
  "C-4": {
83
100
  title: string;
84
101
  fix: string;
102
+ impact: string;
85
103
  };
86
104
  "C-6": {
87
105
  title: string;
88
106
  fix: string;
107
+ impact: string;
89
108
  };
90
109
  "C-7": {
91
110
  title: string;
92
111
  fix: string;
112
+ impact: string;
93
113
  };
94
114
  "D-2": {
95
115
  title: string;
96
116
  fix: string;
117
+ impact: string;
97
118
  };
98
119
  "E-1": {
99
120
  title: string;
100
121
  fix: string;
122
+ impact: string;
101
123
  };
102
124
  "E-2": {
103
125
  title: string;
104
126
  fix: string;
127
+ impact: string;
105
128
  };
106
129
  "E-3": {
107
130
  title: string;
108
131
  fix: string;
132
+ impact: string;
109
133
  };
110
134
  "E-5": {
111
135
  title: string;
112
136
  fix: string;
137
+ impact: string;
113
138
  };
114
139
  "F-1": {
115
140
  title: string;
116
141
  fix: string;
142
+ impact: string;
117
143
  };
118
144
  "G-1": {
119
145
  title: string;
120
146
  fix: string;
147
+ impact: string;
121
148
  };
122
149
  };
123
150
  /** Finding messages. Quotes («…»), URLs and code come in already formatted. */
@@ -140,6 +167,7 @@ export declare const en: {
140
167
  noId: (type: string, name: string) => string;
141
168
  noSameAs: (type: string, name: string) => string;
142
169
  noJobTitle: (name: string) => string;
170
+ entityNotOnHome: (url: string) => string;
143
171
  authorNoId: (name: string) => string;
144
172
  authorUnknownId: (id: string) => string;
145
173
  articleMissing: (type: string, fields: string) => string;
@@ -149,6 +177,13 @@ export declare const en: {
149
177
  introFirst: (q: string) => string;
150
178
  noLongParagraph: string;
151
179
  notFound: (path: string) => string;
180
+ llmsMissing: string;
181
+ answers: (path: string, status: string) => string;
182
+ skipNoArticles: string;
183
+ skipNoLlms: string;
184
+ skipNoFeed: string;
185
+ skipOneLanguage: string;
186
+ skipNoSitemap: string;
152
187
  returnsHtml: string;
153
188
  noH1: string;
154
189
  noSummary: string;
package/dist/i18n/en.js CHANGED
@@ -5,14 +5,19 @@
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. */
12
16
  recommendation: "Recommendation",
13
17
  more: (n) => `…and ${n} more`,
14
18
  checklist: "Checklist",
15
- legend: "✓ OK · ✖ error · ⚠ warning · ℹ note",
19
+ legend: "✓ OK · ✖ error · ⚠ warning · ℹ note · – not checked",
20
+ notChecked: "not checked",
16
21
  /** Findings of one rule in the checklist: «2 errors, 1 warning». */
17
22
  count: (o) => [
18
23
  o.errors && `${o.errors} ${o.errors === 1 ? "error" : "errors"}`,
@@ -37,82 +42,102 @@ export const en = {
37
42
  "A-1": {
38
43
  title: "robots.txt lets AI search agents in",
39
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.",
40
46
  },
41
47
  "A-2": {
42
48
  title: "Training crawlers follow your policy",
43
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.",
44
51
  },
45
52
  "A-3": {
46
53
  title: "The server answers AI agents like a browser",
47
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.",
48
56
  },
49
57
  "A-4": {
50
58
  title: "No noindex or noai on public pages",
51
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.",
52
61
  },
53
62
  "A-5": {
54
63
  title: "The feed is declared in <head>",
55
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.",
56
66
  },
57
67
  "B-1": {
58
68
  title: "The text is in the HTML, not only in JavaScript",
59
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.",
60
71
  },
61
72
  "B-2": {
62
73
  title: "No invisible characters inside words in machine-readable output",
63
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.",
64
76
  },
65
77
  "B-3": {
66
78
  title: "Headings do not glue words together",
67
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.",
68
81
  },
69
82
  "C-1": {
70
83
  title: "The author or company is a defined entity",
71
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.",
72
86
  },
73
87
  "C-2": {
74
88
  title: "Articles point at the author entity by @id",
75
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.",
76
91
  },
77
92
  "C-4": {
78
93
  title: "Articles have complete Article / BlogPosting markup",
79
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.",
80
96
  },
81
97
  "C-6": {
82
98
  title: "Every page has a link card: og:title, og:description, og:image",
83
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.",
84
101
  },
85
102
  "C-7": {
86
103
  title: "A visible FAQ is marked up as FAQPage",
87
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.",
88
106
  },
89
107
  "D-2": {
90
108
  title: "Articles answer in the first two paragraphs",
91
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.",
92
111
  },
93
112
  "E-1": {
94
- title: "llms.txt exists and follows llmstxt.org",
95
- 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.",
96
116
  },
97
117
  "E-2": {
98
118
  title: "Links in llms.txt work and match the sitemap",
99
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.",
100
121
  },
101
122
  "E-3": {
102
123
  title: "Each language has its own llms.txt",
103
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.",
104
126
  },
105
127
  "E-5": {
106
128
  title: "llms-full.txt carries the full text",
107
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.",
108
131
  },
109
132
  "F-1": {
110
133
  title: "Sitemap lastmod matches the page's dateModified",
111
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.",
112
136
  },
113
137
  "G-1": {
114
138
  title: "No instructions addressed to AI in the content",
115
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.",
116
141
  },
117
142
  },
118
143
  /** Finding messages. Quotes («…»), URLs and code come in already formatted. */
@@ -135,6 +160,7 @@ export const en = {
135
160
  noId: (type, name) => `${type} «${name}» has no @id: articles cannot point at it as one entity`,
136
161
  noSameAs: (type, name) => `${type} «${name}» has no sameAs (LinkedIn, GitHub…): models cannot confirm who this is`,
137
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}`,
138
164
  authorNoId: (name) => `author ${name} has no @id`,
139
165
  authorUnknownId: (id) => `author @id ${id} matches no Person or Organization defined on the site`,
140
166
  articleMissing: (type, fields) => `${type} without ${fields}`,
@@ -144,6 +170,13 @@ export const en = {
144
170
  introFirst: (q) => `starts with an intro instead of the answer: ${q}`,
145
171
  noLongParagraph: "no paragraph of 25+ words among the first two",
146
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",
174
+ answers: (path, status) => `${path} answers ${status}, so AI agents cannot read it`,
175
+ skipNoArticles: "no articles among the audited pages",
176
+ skipNoLlms: "no llms.txt",
177
+ skipNoFeed: "the site has no feed",
178
+ skipOneLanguage: "the site has one language",
179
+ skipNoSitemap: "no sitemap.xml",
147
180
  returnsHtml: "returns HTML, not Markdown",
148
181
  noH1: "does not start with an H1 `# Name`",
149
182
  noSummary: "no `> summary` line",
@@ -8,17 +8,23 @@ 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;
19
24
  more: (n: number) => string;
20
25
  checklist: string;
21
26
  legend: string;
27
+ notChecked: string;
22
28
  count: (o: {
23
29
  errors: number;
24
30
  warnings: number;
@@ -40,82 +46,102 @@ export declare const LANGS: {
40
46
  "A-1": {
41
47
  title: string;
42
48
  fix: string;
49
+ impact: string;
43
50
  };
44
51
  "A-2": {
45
52
  title: string;
46
53
  fix: string;
54
+ impact: string;
47
55
  };
48
56
  "A-3": {
49
57
  title: string;
50
58
  fix: string;
59
+ impact: string;
51
60
  };
52
61
  "A-4": {
53
62
  title: string;
54
63
  fix: string;
64
+ impact: string;
55
65
  };
56
66
  "A-5": {
57
67
  title: string;
58
68
  fix: string;
69
+ impact: string;
59
70
  };
60
71
  "B-1": {
61
72
  title: string;
62
73
  fix: string;
74
+ impact: string;
63
75
  };
64
76
  "B-2": {
65
77
  title: string;
66
78
  fix: string;
79
+ impact: string;
67
80
  };
68
81
  "B-3": {
69
82
  title: string;
70
83
  fix: string;
84
+ impact: string;
71
85
  };
72
86
  "C-1": {
73
87
  title: string;
74
88
  fix: string;
89
+ impact: string;
75
90
  };
76
91
  "C-2": {
77
92
  title: string;
78
93
  fix: string;
94
+ impact: string;
79
95
  };
80
96
  "C-4": {
81
97
  title: string;
82
98
  fix: string;
99
+ impact: string;
83
100
  };
84
101
  "C-6": {
85
102
  title: string;
86
103
  fix: string;
104
+ impact: string;
87
105
  };
88
106
  "C-7": {
89
107
  title: string;
90
108
  fix: string;
109
+ impact: string;
91
110
  };
92
111
  "D-2": {
93
112
  title: string;
94
113
  fix: string;
114
+ impact: string;
95
115
  };
96
116
  "E-1": {
97
117
  title: string;
98
118
  fix: string;
119
+ impact: string;
99
120
  };
100
121
  "E-2": {
101
122
  title: string;
102
123
  fix: string;
124
+ impact: string;
103
125
  };
104
126
  "E-3": {
105
127
  title: string;
106
128
  fix: string;
129
+ impact: string;
107
130
  };
108
131
  "E-5": {
109
132
  title: string;
110
133
  fix: string;
134
+ impact: string;
111
135
  };
112
136
  "F-1": {
113
137
  title: string;
114
138
  fix: string;
139
+ impact: string;
115
140
  };
116
141
  "G-1": {
117
142
  title: string;
118
143
  fix: string;
144
+ impact: string;
119
145
  };
120
146
  };
121
147
  m: {
@@ -137,6 +163,7 @@ export declare const LANGS: {
137
163
  noId: (type: string, name: string) => string;
138
164
  noSameAs: (type: string, name: string) => string;
139
165
  noJobTitle: (name: string) => string;
166
+ entityNotOnHome: (url: string) => string;
140
167
  authorNoId: (name: string) => string;
141
168
  authorUnknownId: (id: string) => string;
142
169
  articleMissing: (type: string, fields: string) => string;
@@ -146,6 +173,13 @@ export declare const LANGS: {
146
173
  introFirst: (q: string) => string;
147
174
  noLongParagraph: string;
148
175
  notFound: (path: string) => string;
176
+ llmsMissing: string;
177
+ answers: (path: string, status: string) => string;
178
+ skipNoArticles: string;
179
+ skipNoLlms: string;
180
+ skipNoFeed: string;
181
+ skipOneLanguage: string;
182
+ skipNoSitemap: string;
149
183
  returnsHtml: string;
150
184
  noH1: string;
151
185
  noSummary: string;
@@ -166,17 +200,23 @@ export declare const LANGS: {
166
200
  summary: (o: {
167
201
  score: number;
168
202
  grade: string;
203
+ checked: number;
204
+ total: number;
169
205
  errors: number;
170
206
  warnings: number;
171
207
  notes: number;
172
208
  pages: number;
173
209
  }) => string;
210
+ capped: (checked: number, total: number) => string;
211
+ priorities: string;
212
+ scale: (n: number, total: number) => string;
174
213
  clean: string;
175
214
  fix: string;
176
215
  recommendation: string;
177
216
  more: (n: number) => string;
178
217
  checklist: string;
179
218
  legend: string;
219
+ notChecked: string;
180
220
  count: (o: {
181
221
  errors: number;
182
222
  warnings: number;
@@ -198,82 +238,102 @@ export declare const LANGS: {
198
238
  "A-1": {
199
239
  title: string;
200
240
  fix: string;
241
+ impact: string;
201
242
  };
202
243
  "A-2": {
203
244
  title: string;
204
245
  fix: string;
246
+ impact: string;
205
247
  };
206
248
  "A-3": {
207
249
  title: string;
208
250
  fix: string;
251
+ impact: string;
209
252
  };
210
253
  "A-4": {
211
254
  title: string;
212
255
  fix: string;
256
+ impact: string;
213
257
  };
214
258
  "A-5": {
215
259
  title: string;
216
260
  fix: string;
261
+ impact: string;
217
262
  };
218
263
  "B-1": {
219
264
  title: string;
220
265
  fix: string;
266
+ impact: string;
221
267
  };
222
268
  "B-2": {
223
269
  title: string;
224
270
  fix: string;
271
+ impact: string;
225
272
  };
226
273
  "B-3": {
227
274
  title: string;
228
275
  fix: string;
276
+ impact: string;
229
277
  };
230
278
  "C-1": {
231
279
  title: string;
232
280
  fix: string;
281
+ impact: string;
233
282
  };
234
283
  "C-2": {
235
284
  title: string;
236
285
  fix: string;
286
+ impact: string;
237
287
  };
238
288
  "C-4": {
239
289
  title: string;
240
290
  fix: string;
291
+ impact: string;
241
292
  };
242
293
  "C-6": {
243
294
  title: string;
244
295
  fix: string;
296
+ impact: string;
245
297
  };
246
298
  "C-7": {
247
299
  title: string;
248
300
  fix: string;
301
+ impact: string;
249
302
  };
250
303
  "D-2": {
251
304
  title: string;
252
305
  fix: string;
306
+ impact: string;
253
307
  };
254
308
  "E-1": {
255
309
  title: string;
256
310
  fix: string;
311
+ impact: string;
257
312
  };
258
313
  "E-2": {
259
314
  title: string;
260
315
  fix: string;
316
+ impact: string;
261
317
  };
262
318
  "E-3": {
263
319
  title: string;
264
320
  fix: string;
321
+ impact: string;
265
322
  };
266
323
  "E-5": {
267
324
  title: string;
268
325
  fix: string;
326
+ impact: string;
269
327
  };
270
328
  "F-1": {
271
329
  title: string;
272
330
  fix: string;
331
+ impact: string;
273
332
  };
274
333
  "G-1": {
275
334
  title: string;
276
335
  fix: string;
336
+ impact: string;
277
337
  };
278
338
  };
279
339
  m: {
@@ -295,6 +355,7 @@ export declare const LANGS: {
295
355
  noId: (type: string, name: string) => string;
296
356
  noSameAs: (type: string, name: string) => string;
297
357
  noJobTitle: (name: string) => string;
358
+ entityNotOnHome: (url: string) => string;
298
359
  authorNoId: (name: string) => string;
299
360
  authorUnknownId: (id: string) => string;
300
361
  articleMissing: (type: string, fields: string) => string;
@@ -304,6 +365,13 @@ export declare const LANGS: {
304
365
  introFirst: (q: string) => string;
305
366
  noLongParagraph: string;
306
367
  notFound: (path: string) => string;
368
+ llmsMissing: string;
369
+ answers: (path: string, status: string) => string;
370
+ skipNoArticles: string;
371
+ skipNoLlms: string;
372
+ skipNoFeed: string;
373
+ skipOneLanguage: string;
374
+ skipNoSitemap: string;
307
375
  returnsHtml: string;
308
376
  noH1: string;
309
377
  noSummary: string;
package/dist/i18n/ru.js CHANGED
@@ -2,13 +2,17 @@
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: "Рекомендация",
9
12
  more: (n) => `…и ещё ${n}`,
10
13
  checklist: "Чек-лист",
11
- legend: "✓ в порядке · ✖ ошибка · ⚠ предупреждение · ℹ заметка",
14
+ legend: "✓ в порядке · ✖ ошибка · ⚠ предупреждение · ℹ заметка · – не проверено",
15
+ notChecked: "не проверено",
12
16
  count: (o) => [o.errors && `ошибок: ${o.errors}`, o.warnings && `предупреждений: ${o.warnings}`, o.notes && `заметок: ${o.notes}`].filter(Boolean).join(", "),
13
17
  site: "сайт",
14
18
  footer: (pages, date) => `Проверено страниц: ${pages}, ${date}. После каждого деплоя проверьте вручную: боевой CDN и защиту от ботов с настоящими AI-агентами (A-3), Google Search Console, Bing Webmaster Tools (поиск ChatGPT опирается на Bing), Яндекс Вебмастер.`,
@@ -26,82 +30,102 @@ export const ru = {
26
30
  "A-1": {
27
31
  title: "robots.txt пускает поисковых AI-агентов",
28
32
  fix: "Уберите Disallow для этих агентов (или добавьте `Allow: /` в их группу). Они загружают страницы, чтобы ответить на вопрос и сослаться на источник; боты обучения — отдельное решение (A-2).",
33
+ impact: "AI-поиску запрещено читать сайт — он не может на вас ссылаться.",
29
34
  },
30
35
  "A-2": {
31
36
  title: "Боты обучения следуют вашей политике",
32
37
  fix: "Решите, могут ли будущие модели учиться на сайте, и укажите это в robots.txt отдельной группой для каждого бота. Запрет для них не убирает сайт из AI-поиска (A-1).",
38
+ impact: "Не решено, могут ли будущие модели учиться на вашем сайте.",
33
39
  },
34
40
  "A-3": {
35
41
  title: "Сервер отвечает AI-агентам так же, как браузеру",
36
42
  fix: "Проверьте CDN, WAF или защиту от ботов: эти агенты получают ошибку, проверку или другую страницу. Разрешите их по User-Agent или по подтверждённым диапазонам IP.",
43
+ impact: "AI-агенты получают ошибку или проверку вместо вашего контента.",
37
44
  },
38
45
  "A-4": {
39
46
  title: "На публичных страницах нет noindex и noai",
40
47
  fix: 'Уберите `noindex`, `none`, `noai` из `<meta name="robots">` и заголовка `X-Robots-Tag` на страницах, которые должны цитироваться (на превью и staging их оставьте).',
48
+ impact: "Страницы помечены как закрытые — AI-поиск их пропускает.",
41
49
  },
42
50
  "A-5": {
43
51
  title: "Лента объявлена в <head>",
44
52
  fix: 'Добавьте на страницы `<link rel="alternate" type="application/rss+xml" href="…">`, чтобы краулеры находили новое без угадывания адреса.',
53
+ impact: "Новые страницы находят позже, чем могли бы.",
45
54
  },
46
55
  "B-1": {
47
56
  title: "Текст есть в HTML, а не только в JavaScript",
48
57
  fix: "Рендерите контент на сервере (SSR или статическая генерация). Большинство AI-краулеров не выполняют JavaScript: чего нет в HTML, того для них не существует.",
58
+ impact: "ChatGPT, Claude и Perplexity видят пустую страницу: текст появляется только после скриптов.",
49
59
  },
50
60
  "B-2": {
51
61
  title: "В машиночитаемых версиях нет невидимых символов внутри слов",
52
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: "Скрытые символы рвут слова — текст не совпадает с тем, что ищут люди.",
53
64
  },
54
65
  "B-3": {
55
66
  title: "Заголовки не склеивают слова",
56
67
  fix: 'Поставьте пробел между соседними элементами внутри заголовка (в JSX: `{" "}`). Стили показывают две строки, а краулер читает текстовые узлы подряд: «ИмяСлоган».',
68
+ impact: "Заголовки читаются слипшимися — AI неверно понимает, о чём страница.",
57
69
  },
58
70
  "C-1": {
59
71
  title: "Автор или компания описаны как сущность",
60
72
  fix: "Добавьте на главную или страницу «О себе» JSON-LD Person (или Organization) с постоянным `@id` (например, https://example.com/#person), `name`, `url`, `sameAs` со ссылками на профили и `jobTitle` для человека.",
73
+ impact: "AI-поиск не может понять, кто стоит за сайтом, и меньше ему доверяет.",
61
74
  },
62
75
  "C-2": {
63
76
  title: "Статьи ссылаются на автора через @id",
64
77
  fix: 'Укажите `author: { "@id": "https://example.com/#person" }` (name и url можно оставить) — тот же @id, что у Person на главной.',
78
+ impact: "Статьи не привязаны к автору — экспертиза не складывается в одно имя.",
65
79
  },
66
80
  "C-4": {
67
81
  title: "У статей полная разметка Article / BlogPosting",
68
82
  fix: "Заполните `headline`, `datePublished`, `author` (обязательно), а также `dateModified`, `image`, `inLanguage`.",
83
+ impact: "У статей нет дат или авторства — AI-поиск считает их менее надёжными.",
69
84
  },
70
85
  "C-6": {
71
86
  title: "У каждой страницы есть карточка ссылки: og:title, og:description, og:image",
72
87
  fix: "Добавьте og:image (1200×630) и og:title / og:description на каждую публичную страницу. По ним мессенджеры, соцсети и AI-ответы строят карточку ссылки.",
88
+ impact: "Ссылки на сайт в мессенджерах и AI-ответах выглядят пустыми: без заголовка и картинки.",
73
89
  },
74
90
  "C-7": {
75
91
  title: "Видимый FAQ размечен как FAQPage",
76
92
  fix: "Если страница действительно состоит из вопросов и ответов, добавьте JSON-LD FAQPage с теми же вопросами и ответами, что на странице.",
93
+ impact: "Вопросы и ответы на странице не размечены — их реже берут в ответы.",
77
94
  },
78
95
  "D-2": {
79
96
  title: "Статьи отвечают в первых двух абзацах",
80
97
  fix: "Начинайте с ответа: что это, для кого, главный вывод — от 25 слов. AI-поиск чаще всего цитирует первые абзацы; вступление вроде «В этой статье мы расскажем…» тратит их впустую.",
98
+ impact: "Ответ спрятан за вступлением, а AI-поиск цитирует начало.",
81
99
  },
82
100
  "E-1": {
83
- title: "llms.txt есть и соответствует llmstxt.org",
84
- fix: "Отдавайте /llms.txt в Markdown: `# Название сайта`, строка `> краткое описание`, затем разделы `## …` со ссылками `- [Заголовок](url): пояснение` на ключевые страницы.",
101
+ title: "llms.txt соответствует llmstxt.org (необязательно)",
102
+ fix: "llms.txt необязателен: AI-поиск его почти не читает, а агенты для разработки и инструменты документации читают — он нужен сайтам с документацией, API и продуктами для разработчиков. Если публикуете, отдавайте Markdown: `# Название сайта`, строка `> краткое описание`, затем разделы `## …` со ссылками `- [Заголовок](url): пояснение` на ключевые страницы.",
103
+ impact: "Опубликованный llms.txt не читается так, как ожидают агенты, которые им пользуются.",
85
104
  },
86
105
  "E-2": {
87
106
  title: "Ссылки в llms.txt работают и совпадают с sitemap",
88
107
  fix: "Исправьте или уберите битые ссылки; оставляйте только опубликованные страницы. Ссылка, которая есть в llms.txt, но не в sitemap.xml, — часто черновик или устаревший адрес.",
108
+ impact: "Агентов, которые пользуются llms.txt, отправляют на битые или неопубликованные страницы.",
89
109
  },
90
110
  "E-3": {
91
111
  title: "У каждого языка свой llms.txt",
92
112
  fix: "Для многоязычного сайта отдавайте llms.txt на каждом языке (например, /llms.txt и /ru/llms.txt) и связывайте их ссылками друг на друга.",
113
+ impact: "Модели получают путеводитель по сайту только на одном языке.",
93
114
  },
94
115
  "E-5": {
95
116
  title: "llms-full.txt содержит полный текст",
96
117
  fix: "Отдавайте /llms-full.txt с полным текстом ключевых страниц и статей, чтобы модель могла прочитать сайт за один запрос.",
118
+ impact: "Модель не может прочитать содержимое сайта за один запрос.",
97
119
  },
98
120
  "F-1": {
99
121
  title: "lastmod в sitemap совпадает с dateModified страницы",
100
122
  fix: "Берите lastmod из той же даты, что и dateModified в JSON-LD: расхождение сообщает краулерам об изменениях, которых не было, или скрывает настоящие обновления.",
123
+ impact: "Поисковики получают противоречивые сигналы о свежести страниц.",
101
124
  },
102
125
  "G-1": {
103
126
  title: "В контенте нет указаний, обращённых к AI",
104
127
  fix: "Уберите строки, которые говорят AI-системам, что сказать или сделать. AI-поиск считает их prompt injection и может отбросить страницу или меньше доверять сайту. Пасхалка для людей может жить в humans.txt или в консоли.",
128
+ impact: "Обращения к AI выглядят как попытка манипуляции и снижают доверие к сайту.",
105
129
  },
106
130
  },
107
131
  m: {
@@ -123,6 +147,7 @@ export const ru = {
123
147
  noId: (type, name) => `у ${type} «${name}» нет @id: статьи не могут сослаться на него как на одну сущность`,
124
148
  noSameAs: (type, name) => `у ${type} «${name}» нет sameAs (LinkedIn, GitHub…): модели не могут подтвердить, кто это`,
125
149
  noJobTitle: (name) => `у Person «${name}» нет jobTitle`,
150
+ entityNotOnHome: (url) => `на главной нет описания компании или автора — оно есть только на ${url}`,
126
151
  authorNoId: (name) => `у автора ${name} нет @id`,
127
152
  authorUnknownId: (id) => `@id автора ${id} не совпадает ни с одним Person или Organization на сайте`,
128
153
  articleMissing: (type, fields) => `в ${type} нет ${fields}`,
@@ -132,6 +157,13 @@ export const ru = {
132
157
  introFirst: (q) => `начинается со вступления, а не с ответа: ${q}`,
133
158
  noLongParagraph: "среди первых двух абзацев нет абзаца от 25 слов",
134
159
  notFound: (path) => `${path} не найден`,
160
+ llmsMissing: "нет /llms.txt — это необязательно: AI-поиск его почти не читает, он полезен документации, API и агентам для разработки",
161
+ answers: (path, status) => `${path} отвечает ${status}, AI-агенты не могут его прочитать`,
162
+ skipNoArticles: "среди проверенных страниц нет статей",
163
+ skipNoLlms: "нет llms.txt",
164
+ skipNoFeed: "на сайте нет ленты",
165
+ skipOneLanguage: "сайт на одном языке",
166
+ skipNoSitemap: "нет sitemap.xml",
135
167
  returnsHtml: "отдаёт HTML, а не Markdown",
136
168
  noH1: "не начинается с заголовка `# Название`",
137
169
  noSummary: "нет строки `> описание`",
package/dist/index.d.ts CHANGED
@@ -14,12 +14,15 @@ export interface Report extends Score {
14
14
  checkedAt: string;
15
15
  pages: string[];
16
16
  findings: Finding[];
17
+ /** `skipped` is the reason a check did not run on this site. */
17
18
  rules: {
18
19
  id: string;
19
20
  group: Group;
20
21
  level: Level;
21
22
  title: string;
22
23
  fix: string;
24
+ impact: string;
25
+ skipped?: string;
23
26
  }[];
24
27
  }
25
28
  export declare function audit(target: string, opts?: {
package/dist/index.js CHANGED
@@ -11,14 +11,16 @@ export async function audit(target, opts = {}) {
11
11
  const t = messages(config.lang);
12
12
  const site = await crawl(target, opts.base, { maxPages: config.maxPages, urls: opts.urls, log: opts.log });
13
13
  const active = rules.filter((r) => !config.disable.includes(r.id));
14
- const findings = active.flatMap((r) => r.run(site, config, t.m));
14
+ // A check that cannot run here is reported as «not checked» with the reason, never as passed.
15
+ const skipped = new Map(active.map((r) => [r.id, r.applies?.(site, t.m) ?? null]));
16
+ const findings = active.filter((r) => !skipped.get(r.id)).flatMap((r) => r.run(site, config, t.m));
15
17
  return {
16
18
  site: site.base,
17
19
  lang: config.lang,
18
20
  checkedAt: new Date().toISOString(),
19
21
  pages: site.pages.map((p) => p.fetched.publicUrl),
20
- ...scoreOf(findings),
22
+ ...scoreOf(findings, { pages: site.pages.length, checked: active.filter((r) => !skipped.get(r.id)).length, total: active.length }),
21
23
  findings,
22
- rules: active.map(({ id, group, level }) => ({ id, group, level, ...t.rules[id] })),
24
+ rules: active.map(({ id, group, level }) => ({ id, group, level, ...t.rules[id], ...(skipped.get(id) ? { skipped: skipped.get(id) } : {}) })),
23
25
  };
24
26
  }
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") }))
@@ -37,6 +53,10 @@ export function toMarkdown(r) {
37
53
  continue;
38
54
  lines.push(`**${g}. ${t.groups[g]}**`, "");
39
55
  for (const rule of list) {
56
+ if (rule.skipped) {
57
+ lines.push(`- – ${rule.id} ${rule.title} — ${t.notChecked}: ${rule.skipped}`);
58
+ continue;
59
+ }
40
60
  const found = byRule.get(rule.id) ?? [];
41
61
  const n = (level) => found.filter((f) => f.level === level).length;
42
62
  const icon = found.length ? ICON[found.reduce((w, f) => (ORDER[f.level] < ORDER[w] ? f.level : w), "info")] : "✓";
@@ -81,6 +81,9 @@ export const accessRules = [
81
81
  id: "A-5",
82
82
  group: "A",
83
83
  level: "info",
84
+ applies(site, t) {
85
+ return site.feeds.length || site.undeclaredFeed ? null : t.skipNoFeed;
86
+ },
84
87
  run(site, _config, t) {
85
88
  const { out, add } = finder(this);
86
89
  if (site.undeclaredFeed)
@@ -13,11 +13,16 @@ const TO_AI = [
13
13
  /игнорируй(те)? (все )?(предыдущие|прошлые) инструкции/iu,
14
14
  ];
15
15
  const datePart = (s) => s.slice(0, 10);
16
+ /** llms.txt that answered 200 with text, not an HTML page served at that address (a common SPA fallback). */
17
+ const llmsReadable = (site) => Boolean(site.llms && !/^\s*<(!doctype|html)/i.test(site.llms.body));
16
18
  export const contentRules = [
17
19
  {
18
20
  id: "D-2",
19
21
  group: "D",
20
22
  level: "warn",
23
+ applies(site, t) {
24
+ return site.pages.some(isArticle) ? null : t.skipNoArticles;
25
+ },
21
26
  run(site, _config, t) {
22
27
  const { out, add } = finder(this);
23
28
  for (const page of site.pages) {
@@ -38,8 +43,11 @@ export const contentRules = [
38
43
  level: "warn",
39
44
  run(site, _config, t) {
40
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.
41
48
  if (!site.llms) {
42
- add("site", t.notFound("/llms.txt"));
49
+ const a = site.llmsAttempt;
50
+ add("site", a.status === 404 || a.status === 410 ? t.llmsMissing : t.answers("/llms.txt", a.error ?? `HTTP ${a.status}`), "info");
43
51
  return out;
44
52
  }
45
53
  const body = site.llms.body;
@@ -60,7 +68,10 @@ export const contentRules = [
60
68
  {
61
69
  id: "E-2",
62
70
  group: "E",
63
- level: "error",
71
+ level: "warn",
72
+ applies(site, t) {
73
+ return llmsReadable(site) ? null : t.skipNoLlms;
74
+ },
64
75
  run(site, _config, t) {
65
76
  const { out, add } = finder(this);
66
77
  const inSitemap = new Set((site.sitemap?.urls ?? []).map((u) => u.loc.replace(/\/$/, "")));
@@ -68,7 +79,7 @@ export const contentRules = [
68
79
  if (status !== 200)
69
80
  add(href, t.brokenLink(status ? String(status) : t.nothing));
70
81
  else if (site.sitemap && /\/[^./]*$/.test(new URL(href).pathname) && !inSitemap.has(href.replace(/\/$/, "")))
71
- add(href, t.notInSitemap, "warn");
82
+ add(href, t.notInSitemap, "info");
72
83
  }
73
84
  return out;
74
85
  },
@@ -77,6 +88,12 @@ export const contentRules = [
77
88
  id: "E-3",
78
89
  group: "E",
79
90
  level: "info",
91
+ applies(site, t) {
92
+ if (!llmsReadable(site))
93
+ return t.skipNoLlms;
94
+ const langs = new Set(site.pages.flatMap((p) => p.hreflang.map((h) => h.lang)).filter((l) => l !== "x-default"));
95
+ return langs.size > 1 ? null : t.skipOneLanguage;
96
+ },
80
97
  run(site, _config, t) {
81
98
  const { out, add } = finder(this);
82
99
  const langs = new Set(site.pages.flatMap((p) => p.hreflang.map((h) => h.lang)).filter((l) => l !== "x-default"));
@@ -89,6 +106,9 @@ export const contentRules = [
89
106
  id: "E-5",
90
107
  group: "E",
91
108
  level: "info",
109
+ applies(site, t) {
110
+ return llmsReadable(site) ? null : t.skipNoLlms;
111
+ },
92
112
  run(site, _config, t) {
93
113
  const { out, add } = finder(this);
94
114
  if (!site.llmsFull) {
@@ -110,6 +130,11 @@ export const contentRules = [
110
130
  id: "F-1",
111
131
  group: "F",
112
132
  level: "warn",
133
+ applies(site, t) {
134
+ if (!site.sitemap)
135
+ return t.skipNoSitemap;
136
+ return site.pages.some(isArticle) ? null : t.skipNoArticles;
137
+ },
113
138
  run(site, _config, t) {
114
139
  const { out, add } = finder(this);
115
140
  if (!site.sitemap)
@@ -1,5 +1,5 @@
1
1
  import { types } from "../html.js";
2
- import { ENTITY_TYPES, articleNode, finder, isA, str } from "./util.js";
2
+ import { ENTITY_TYPES, articleNode, finder, isA, isArticle, str } from "./util.js";
3
3
  const idOf = (v) => (v && typeof v === "object" ? str(v["@id"]) : "");
4
4
  const authors = (node) => {
5
5
  const a = node.author;
@@ -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);
@@ -48,6 +52,9 @@ export const entityRules = [
48
52
  id: "C-2",
49
53
  group: "C",
50
54
  level: "warn",
55
+ applies(site, t) {
56
+ return site.pages.some(isArticle) ? null : t.skipNoArticles;
57
+ },
51
58
  run(site, _config, t) {
52
59
  const { out, add } = finder(this);
53
60
  const ids = new Set(definitions(site).map(({ n }) => str(n["@id"])).filter(Boolean));
@@ -70,6 +77,9 @@ export const entityRules = [
70
77
  id: "C-4",
71
78
  group: "C",
72
79
  level: "error",
80
+ applies(site, t) {
81
+ return site.pages.some(isArticle) ? null : t.skipNoArticles;
82
+ },
73
83
  run(site, _config, t) {
74
84
  const { out, add } = finder(this);
75
85
  for (const page of site.pages) {
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/dist/types.d.ts CHANGED
@@ -80,7 +80,9 @@ export interface Site {
80
80
  } | null;
81
81
  sitemap: Sitemap | null;
82
82
  pages: Page[];
83
+ /** /llms.txt when it answered 200, else null; llmsAttempt keeps the raw answer for the message (404, 403…). */
83
84
  llms: Fetched | null;
85
+ llmsAttempt: Fetched;
84
86
  llmsFull: Fetched | null;
85
87
  feeds: Fetched[];
86
88
  /** Same page fetched with each AI user agent (rule A-3). */
@@ -110,5 +112,10 @@ export interface Rule {
110
112
  id: RuleId;
111
113
  group: Group;
112
114
  level: Level;
115
+ /**
116
+ * Why the check cannot run on this site («no articles among the audited pages», «no llms.txt»), or null when it can.
117
+ * A check that does not apply is shown as «not checked» instead of a green tick.
118
+ */
119
+ applies?(site: Site, t: Messages["m"]): string | null;
113
120
  run(site: Site, config: Config, t: Messages["m"]): Finding[];
114
121
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-search-auditor",
3
- "version": "0.1.4",
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",