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 +32 -0
- package/README.md +3 -3
- package/dist/i18n/en.d.ts +28 -0
- package/dist/i18n/en.js +29 -3
- package/dist/i18n/index.d.ts +54 -0
- package/dist/i18n/ru.js +28 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -1
- package/dist/report.js +17 -1
- package/dist/rules/content.js +5 -3
- package/dist/rules/entities.js +4 -0
- package/dist/score.d.ts +21 -3
- package/dist/score.js +38 -12
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
96
|
-
fix: "
|
|
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",
|
package/dist/i18n/index.d.ts
CHANGED
|
@@ -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
|
|
85
|
-
fix: "
|
|
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
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") }))
|
package/dist/rules/content.js
CHANGED
|
@@ -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.
|
|
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: "
|
|
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, "
|
|
82
|
+
add(href, t.notInSitemap, "info");
|
|
81
83
|
}
|
|
82
84
|
return out;
|
|
83
85
|
},
|
package/dist/rules/entities.js
CHANGED
|
@@ -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
|
|
9
|
-
*
|
|
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
|
-
*
|
|
4
|
-
*
|
|
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
|
|
7
|
-
const worst =
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
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.
|
|
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",
|