ai-search-auditor 0.1.2 → 0.1.5
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 +22 -0
- package/README.md +1 -1
- package/dist/cli.js +14 -3
- package/dist/crawl.d.ts +3 -0
- package/dist/crawl.js +25 -5
- package/dist/i18n/en.d.ts +7 -0
- package/dist/i18n/en.js +8 -1
- package/dist/i18n/index.d.ts +14 -0
- package/dist/i18n/ru.js +8 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -2
- package/dist/report.js +4 -0
- package/dist/rules/access.js +3 -0
- package/dist/rules/content.js +24 -1
- package/dist/rules/entities.js +7 -1
- package/dist/types.d.ts +7 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.5](https://github.com/a-bibikov/ai-search-audit/compare/v0.1.4...v0.1.5) (2026-11-03)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Fixes
|
|
7
|
+
|
|
8
|
+
* checks that cannot run are marked not checked, not passed ([e01eec5](https://github.com/a-bibikov/ai-search-audit/commit/e01eec5674c5f313ffa7b957af6c3520b86e840c))
|
|
9
|
+
* checks that cannot run are marked not checked, not passed ([9281b96](https://github.com/a-bibikov/ai-search-audit/commit/9281b96ad2c6dee10d4a756b11e825fe858fc563))
|
|
10
|
+
|
|
11
|
+
## [0.1.4](https://github.com/a-bibikov/ai-search-audit/compare/v0.1.3...v0.1.4) (2026-11-03)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Internal
|
|
15
|
+
|
|
16
|
+
* release 0.1.4 ([2c8056d](https://github.com/a-bibikov/ai-search-audit/commit/2c8056d386588262362e66c5fce78726df9127d8))
|
|
17
|
+
|
|
18
|
+
## [0.1.3](https://github.com/a-bibikov/ai-search-audit/compare/v0.1.2...v0.1.3) (2026-11-03)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Fixes
|
|
22
|
+
|
|
23
|
+
* CLI help names the npm package, --version flag ([31887fa](https://github.com/a-bibikov/ai-search-audit/commit/31887fada369eb41024f119b79359e5a98c03126))
|
|
24
|
+
|
|
3
25
|
## [0.1.2](https://github.com/a-bibikov/ai-search-audit/compare/v0.1.1...v0.1.2) (2026-11-03)
|
|
4
26
|
|
|
5
27
|
|
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ Gaps in the numbering are taken by the checks on the [roadmap](#roadmap).
|
|
|
68
68
|
|
|
69
69
|
Levels: `error` — AI search cannot read the page or reads it wrong; `warn` — lowers the chance of a citation; `info` — a recommendation.
|
|
70
70
|
|
|
71
|
-
The report lists the findings with fixes first, then ends with a checklist of every check by group: ✓ OK, ✖ error, ⚠ warning, ℹ note.
|
|
71
|
+
The report lists the findings with fixes first, then ends with a checklist of every check by group: ✓ OK, ✖ error, ⚠ warning, ℹ note, – not checked. A check that cannot run on a site — articles on a site without articles, `llms.txt` links when there is no `llms.txt` — is marked «not checked» with the reason, never as passed.
|
|
72
72
|
|
|
73
73
|
**Score:** 100 minus 15 per rule with an error and 5 per rule with a warning. A rule counts once, not per page, so one template bug does not sink a large site. A ≥ 90, B ≥ 80, C ≥ 70, D ≥ 60, F below.
|
|
74
74
|
|
package/dist/cli.js
CHANGED
|
@@ -2,11 +2,16 @@
|
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
4
|
import { DEFAULT_LANG, LANGS, audit, isLang, rules, toMarkdown } from "./index.js";
|
|
5
|
-
|
|
5
|
+
// package.json sits one level up both from src/cli.ts (development) and dist/cli.js (published package).
|
|
6
|
+
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
7
|
+
const HELP = `ai-search-auditor ${version} — can ChatGPT, Claude and Perplexity read, understand and cite your site?
|
|
6
8
|
|
|
7
9
|
Usage:
|
|
8
|
-
ai-search-
|
|
9
|
-
ai-search-
|
|
10
|
+
ai-search-auditor <url> [options]
|
|
11
|
+
ai-search-auditor llms-txt <url> check only llms.txt and llms-full.txt
|
|
12
|
+
|
|
13
|
+
npm package: ai-search-auditor (npx ai-search-auditor, npm i -g ai-search-auditor).
|
|
14
|
+
After install the command also works as ai-search-audit.
|
|
10
15
|
|
|
11
16
|
Options:
|
|
12
17
|
--base <url> public origin when auditing a local build (links in sitemap point there)
|
|
@@ -16,6 +21,7 @@ Options:
|
|
|
16
21
|
--json print the JSON report instead of Markdown
|
|
17
22
|
--out <file> also write the report to a file (.json or .md)
|
|
18
23
|
--config <file> config file (default: ai-search-audit.config.json if present)
|
|
24
|
+
-v, --version print the version
|
|
19
25
|
-h, --help
|
|
20
26
|
|
|
21
27
|
Examples:
|
|
@@ -34,8 +40,13 @@ const { values, positionals } = parseArgs({
|
|
|
34
40
|
out: { type: "string" },
|
|
35
41
|
config: { type: "string" },
|
|
36
42
|
help: { type: "boolean", short: "h", default: false },
|
|
43
|
+
version: { type: "boolean", short: "v", default: false },
|
|
37
44
|
},
|
|
38
45
|
});
|
|
46
|
+
if (values.version) {
|
|
47
|
+
process.stdout.write(`${version}\n`);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
39
50
|
const llmsOnly = positionals[0] === "llms-txt";
|
|
40
51
|
const target = llmsOnly ? positionals[1] : positionals[0];
|
|
41
52
|
if (values.help || !target) {
|
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
|
@@ -20,6 +20,7 @@ export declare const en: {
|
|
|
20
20
|
more: (n: number) => string;
|
|
21
21
|
checklist: string;
|
|
22
22
|
legend: string;
|
|
23
|
+
notChecked: string;
|
|
23
24
|
/** Findings of one rule in the checklist: «2 errors, 1 warning». */
|
|
24
25
|
count: (o: {
|
|
25
26
|
errors: number;
|
|
@@ -149,6 +150,12 @@ export declare const en: {
|
|
|
149
150
|
introFirst: (q: string) => string;
|
|
150
151
|
noLongParagraph: string;
|
|
151
152
|
notFound: (path: string) => string;
|
|
153
|
+
answers: (path: string, status: string) => string;
|
|
154
|
+
skipNoArticles: string;
|
|
155
|
+
skipNoLlms: string;
|
|
156
|
+
skipNoFeed: string;
|
|
157
|
+
skipOneLanguage: string;
|
|
158
|
+
skipNoSitemap: string;
|
|
152
159
|
returnsHtml: string;
|
|
153
160
|
noH1: string;
|
|
154
161
|
noSummary: string;
|
package/dist/i18n/en.js
CHANGED
|
@@ -12,7 +12,8 @@ export const en = {
|
|
|
12
12
|
recommendation: "Recommendation",
|
|
13
13
|
more: (n) => `…and ${n} more`,
|
|
14
14
|
checklist: "Checklist",
|
|
15
|
-
legend: "✓ OK · ✖ error · ⚠ warning · ℹ note",
|
|
15
|
+
legend: "✓ OK · ✖ error · ⚠ warning · ℹ note · – not checked",
|
|
16
|
+
notChecked: "not checked",
|
|
16
17
|
/** Findings of one rule in the checklist: «2 errors, 1 warning». */
|
|
17
18
|
count: (o) => [
|
|
18
19
|
o.errors && `${o.errors} ${o.errors === 1 ? "error" : "errors"}`,
|
|
@@ -144,6 +145,12 @@ export const en = {
|
|
|
144
145
|
introFirst: (q) => `starts with an intro instead of the answer: ${q}`,
|
|
145
146
|
noLongParagraph: "no paragraph of 25+ words among the first two",
|
|
146
147
|
notFound: (path) => `${path} not found`,
|
|
148
|
+
answers: (path, status) => `${path} answers ${status}, so AI agents cannot read it`,
|
|
149
|
+
skipNoArticles: "no articles among the audited pages",
|
|
150
|
+
skipNoLlms: "no llms.txt",
|
|
151
|
+
skipNoFeed: "the site has no feed",
|
|
152
|
+
skipOneLanguage: "the site has one language",
|
|
153
|
+
skipNoSitemap: "no sitemap.xml",
|
|
147
154
|
returnsHtml: "returns HTML, not Markdown",
|
|
148
155
|
noH1: "does not start with an H1 `# Name`",
|
|
149
156
|
noSummary: "no `> summary` line",
|
package/dist/i18n/index.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export declare const LANGS: {
|
|
|
19
19
|
more: (n: number) => string;
|
|
20
20
|
checklist: string;
|
|
21
21
|
legend: string;
|
|
22
|
+
notChecked: string;
|
|
22
23
|
count: (o: {
|
|
23
24
|
errors: number;
|
|
24
25
|
warnings: number;
|
|
@@ -146,6 +147,12 @@ export declare const LANGS: {
|
|
|
146
147
|
introFirst: (q: string) => string;
|
|
147
148
|
noLongParagraph: string;
|
|
148
149
|
notFound: (path: string) => string;
|
|
150
|
+
answers: (path: string, status: string) => string;
|
|
151
|
+
skipNoArticles: string;
|
|
152
|
+
skipNoLlms: string;
|
|
153
|
+
skipNoFeed: string;
|
|
154
|
+
skipOneLanguage: string;
|
|
155
|
+
skipNoSitemap: string;
|
|
149
156
|
returnsHtml: string;
|
|
150
157
|
noH1: string;
|
|
151
158
|
noSummary: string;
|
|
@@ -177,6 +184,7 @@ export declare const LANGS: {
|
|
|
177
184
|
more: (n: number) => string;
|
|
178
185
|
checklist: string;
|
|
179
186
|
legend: string;
|
|
187
|
+
notChecked: string;
|
|
180
188
|
count: (o: {
|
|
181
189
|
errors: number;
|
|
182
190
|
warnings: number;
|
|
@@ -304,6 +312,12 @@ export declare const LANGS: {
|
|
|
304
312
|
introFirst: (q: string) => string;
|
|
305
313
|
noLongParagraph: string;
|
|
306
314
|
notFound: (path: string) => string;
|
|
315
|
+
answers: (path: string, status: string) => string;
|
|
316
|
+
skipNoArticles: string;
|
|
317
|
+
skipNoLlms: string;
|
|
318
|
+
skipNoFeed: string;
|
|
319
|
+
skipOneLanguage: string;
|
|
320
|
+
skipNoSitemap: string;
|
|
307
321
|
returnsHtml: string;
|
|
308
322
|
noH1: string;
|
|
309
323
|
noSummary: string;
|
package/dist/i18n/ru.js
CHANGED
|
@@ -8,7 +8,8 @@ export const ru = {
|
|
|
8
8
|
recommendation: "Рекомендация",
|
|
9
9
|
more: (n) => `…и ещё ${n}`,
|
|
10
10
|
checklist: "Чек-лист",
|
|
11
|
-
legend: "✓ в порядке · ✖ ошибка · ⚠ предупреждение · ℹ заметка",
|
|
11
|
+
legend: "✓ в порядке · ✖ ошибка · ⚠ предупреждение · ℹ заметка · – не проверено",
|
|
12
|
+
notChecked: "не проверено",
|
|
12
13
|
count: (o) => [o.errors && `ошибок: ${o.errors}`, o.warnings && `предупреждений: ${o.warnings}`, o.notes && `заметок: ${o.notes}`].filter(Boolean).join(", "),
|
|
13
14
|
site: "сайт",
|
|
14
15
|
footer: (pages, date) => `Проверено страниц: ${pages}, ${date}. После каждого деплоя проверьте вручную: боевой CDN и защиту от ботов с настоящими AI-агентами (A-3), Google Search Console, Bing Webmaster Tools (поиск ChatGPT опирается на Bing), Яндекс Вебмастер.`,
|
|
@@ -132,6 +133,12 @@ export const ru = {
|
|
|
132
133
|
introFirst: (q) => `начинается со вступления, а не с ответа: ${q}`,
|
|
133
134
|
noLongParagraph: "среди первых двух абзацев нет абзаца от 25 слов",
|
|
134
135
|
notFound: (path) => `${path} не найден`,
|
|
136
|
+
answers: (path, status) => `${path} отвечает ${status}, AI-агенты не могут его прочитать`,
|
|
137
|
+
skipNoArticles: "среди проверенных страниц нет статей",
|
|
138
|
+
skipNoLlms: "нет llms.txt",
|
|
139
|
+
skipNoFeed: "на сайте нет ленты",
|
|
140
|
+
skipOneLanguage: "сайт на одном языке",
|
|
141
|
+
skipNoSitemap: "нет sitemap.xml",
|
|
135
142
|
returnsHtml: "отдаёт HTML, а не Markdown",
|
|
136
143
|
noH1: "не начинается с заголовка `# Название`",
|
|
137
144
|
noSummary: "нет строки `> описание`",
|
package/dist/index.d.ts
CHANGED
|
@@ -14,12 +14,14 @@ 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
|
+
skipped?: string;
|
|
23
25
|
}[];
|
|
24
26
|
}
|
|
25
27
|
export declare function audit(target: string, opts?: {
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,9 @@ 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,
|
|
@@ -19,6 +21,6 @@ export async function audit(target, opts = {}) {
|
|
|
19
21
|
pages: site.pages.map((p) => p.fetched.publicUrl),
|
|
20
22
|
...scoreOf(findings),
|
|
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
|
@@ -37,6 +37,10 @@ export function toMarkdown(r) {
|
|
|
37
37
|
continue;
|
|
38
38
|
lines.push(`**${g}. ${t.groups[g]}**`, "");
|
|
39
39
|
for (const rule of list) {
|
|
40
|
+
if (rule.skipped) {
|
|
41
|
+
lines.push(`- – ${rule.id} ${rule.title} — ${t.notChecked}: ${rule.skipped}`);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
40
44
|
const found = byRule.get(rule.id) ?? [];
|
|
41
45
|
const n = (level) => found.filter((f) => f.level === level).length;
|
|
42
46
|
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) {
|
|
@@ -39,7 +44,8 @@ export const contentRules = [
|
|
|
39
44
|
run(site, _config, t) {
|
|
40
45
|
const { out, add } = finder(this);
|
|
41
46
|
if (!site.llms) {
|
|
42
|
-
|
|
47
|
+
const a = site.llmsAttempt;
|
|
48
|
+
add("site", a.status === 404 || a.status === 410 ? t.notFound("/llms.txt") : t.answers("/llms.txt", a.error ?? `HTTP ${a.status}`));
|
|
43
49
|
return out;
|
|
44
50
|
}
|
|
45
51
|
const body = site.llms.body;
|
|
@@ -61,6 +67,9 @@ export const contentRules = [
|
|
|
61
67
|
id: "E-2",
|
|
62
68
|
group: "E",
|
|
63
69
|
level: "error",
|
|
70
|
+
applies(site, t) {
|
|
71
|
+
return llmsReadable(site) ? null : t.skipNoLlms;
|
|
72
|
+
},
|
|
64
73
|
run(site, _config, t) {
|
|
65
74
|
const { out, add } = finder(this);
|
|
66
75
|
const inSitemap = new Set((site.sitemap?.urls ?? []).map((u) => u.loc.replace(/\/$/, "")));
|
|
@@ -77,6 +86,12 @@ export const contentRules = [
|
|
|
77
86
|
id: "E-3",
|
|
78
87
|
group: "E",
|
|
79
88
|
level: "info",
|
|
89
|
+
applies(site, t) {
|
|
90
|
+
if (!llmsReadable(site))
|
|
91
|
+
return t.skipNoLlms;
|
|
92
|
+
const langs = new Set(site.pages.flatMap((p) => p.hreflang.map((h) => h.lang)).filter((l) => l !== "x-default"));
|
|
93
|
+
return langs.size > 1 ? null : t.skipOneLanguage;
|
|
94
|
+
},
|
|
80
95
|
run(site, _config, t) {
|
|
81
96
|
const { out, add } = finder(this);
|
|
82
97
|
const langs = new Set(site.pages.flatMap((p) => p.hreflang.map((h) => h.lang)).filter((l) => l !== "x-default"));
|
|
@@ -89,6 +104,9 @@ export const contentRules = [
|
|
|
89
104
|
id: "E-5",
|
|
90
105
|
group: "E",
|
|
91
106
|
level: "info",
|
|
107
|
+
applies(site, t) {
|
|
108
|
+
return llmsReadable(site) ? null : t.skipNoLlms;
|
|
109
|
+
},
|
|
92
110
|
run(site, _config, t) {
|
|
93
111
|
const { out, add } = finder(this);
|
|
94
112
|
if (!site.llmsFull) {
|
|
@@ -110,6 +128,11 @@ export const contentRules = [
|
|
|
110
128
|
id: "F-1",
|
|
111
129
|
group: "F",
|
|
112
130
|
level: "warn",
|
|
131
|
+
applies(site, t) {
|
|
132
|
+
if (!site.sitemap)
|
|
133
|
+
return t.skipNoSitemap;
|
|
134
|
+
return site.pages.some(isArticle) ? null : t.skipNoArticles;
|
|
135
|
+
},
|
|
113
136
|
run(site, _config, t) {
|
|
114
137
|
const { out, add } = finder(this);
|
|
115
138
|
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;
|
|
@@ -48,6 +48,9 @@ export const entityRules = [
|
|
|
48
48
|
id: "C-2",
|
|
49
49
|
group: "C",
|
|
50
50
|
level: "warn",
|
|
51
|
+
applies(site, t) {
|
|
52
|
+
return site.pages.some(isArticle) ? null : t.skipNoArticles;
|
|
53
|
+
},
|
|
51
54
|
run(site, _config, t) {
|
|
52
55
|
const { out, add } = finder(this);
|
|
53
56
|
const ids = new Set(definitions(site).map(({ n }) => str(n["@id"])).filter(Boolean));
|
|
@@ -70,6 +73,9 @@ export const entityRules = [
|
|
|
70
73
|
id: "C-4",
|
|
71
74
|
group: "C",
|
|
72
75
|
level: "error",
|
|
76
|
+
applies(site, t) {
|
|
77
|
+
return site.pages.some(isArticle) ? null : t.skipNoArticles;
|
|
78
|
+
},
|
|
73
79
|
run(site, _config, t) {
|
|
74
80
|
const { out, add } = finder(this);
|
|
75
81
|
for (const page of site.pages) {
|
package/dist/types.d.ts
CHANGED
|
@@ -80,7 +80,9 @@ export interface Site {
|
|
|
80
80
|
} | null;
|
|
81
81
|
sitemap: Sitemap | null;
|
|
82
82
|
pages: Page[];
|
|
83
|
+
/** /llms.txt when it answered 200, else null; llmsAttempt keeps the raw answer for the message (404, 403…). */
|
|
83
84
|
llms: Fetched | null;
|
|
85
|
+
llmsAttempt: Fetched;
|
|
84
86
|
llmsFull: Fetched | null;
|
|
85
87
|
feeds: Fetched[];
|
|
86
88
|
/** Same page fetched with each AI user agent (rule A-3). */
|
|
@@ -110,5 +112,10 @@ export interface Rule {
|
|
|
110
112
|
id: RuleId;
|
|
111
113
|
group: Group;
|
|
112
114
|
level: Level;
|
|
115
|
+
/**
|
|
116
|
+
* Why the check cannot run on this site («no articles among the audited pages», «no llms.txt»), or null when it can.
|
|
117
|
+
* A check that does not apply is shown as «not checked» instead of a green tick.
|
|
118
|
+
*/
|
|
119
|
+
applies?(site: Site, t: Messages["m"]): string | null;
|
|
113
120
|
run(site: Site, config: Config, t: Messages["m"]): Finding[];
|
|
114
121
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-search-auditor",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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",
|