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 +40 -0
- package/README.md +3 -3
- package/dist/crawl.d.ts +3 -0
- package/dist/crawl.js +25 -5
- package/dist/i18n/en.d.ts +35 -0
- package/dist/i18n/en.js +37 -4
- package/dist/i18n/index.d.ts +68 -0
- package/dist/i18n/ru.js +36 -4
- package/dist/index.d.ts +3 -0
- package/dist/index.js +5 -3
- package/dist/report.js +21 -1
- package/dist/rules/access.js +3 -0
- package/dist/rules/content.js +28 -3
- package/dist/rules/entities.js +11 -1
- package/dist/score.d.ts +21 -3
- package/dist/score.js +38 -12
- package/dist/types.d.ts +7 -0
- package/package.json +1 -1
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
|
|
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/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
|
|
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 (
|
|
33
|
-
|
|
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
|
|
95
|
-
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.",
|
|
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",
|
package/dist/i18n/index.d.ts
CHANGED
|
@@ -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
|
|
84
|
-
fix: "
|
|
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
|
-
|
|
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")] : "✓";
|
package/dist/rules/access.js
CHANGED
|
@@ -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)
|
package/dist/rules/content.js
CHANGED
|
@@ -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
|
-
|
|
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: "
|
|
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, "
|
|
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)
|
package/dist/rules/entities.js
CHANGED
|
@@ -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
|
|
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/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.
|
|
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",
|