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 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
- const HELP = `ai-search-audit — can ChatGPT, Claude and Perplexity read, understand and cite your site?
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-audit <url> [options]
9
- ai-search-audit llms-txt <url> check only llms.txt and llms-full.txt
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 path = new URL(url).pathname.replace(/\/$/, "");
21
- const segs = path.split("/").filter(Boolean);
28
+ const segs = segments(url);
22
29
  const key = segs.length <= 1 ? `/${segs.join("/")}` : `/${segs.slice(0, -1).join("/")}/*`;
23
30
  if (!groups.has(key))
24
31
  groups.set(key, []);
25
32
  groups.get(key).push(url);
26
33
  }
27
34
  const picked = [home];
35
+ const tails = new Set();
36
+ const later = [];
28
37
  const lists = [...groups.values()];
29
38
  for (let round = 0; picked.length < max && lists.some((l) => l.length > round); round++) {
30
39
  for (const list of lists) {
31
40
  const url = list[round];
32
- if (url && !picked.includes(url) && picked.length < max)
33
- picked.push(url);
41
+ if (!url || picked.length >= max)
42
+ continue;
43
+ const tail = tailOf(url);
44
+ if (tail && tails.has(tail)) {
45
+ later.push(url);
46
+ continue;
47
+ }
48
+ if (tail)
49
+ tails.add(tail);
50
+ picked.push(url);
34
51
  }
35
52
  }
53
+ for (const url of later)
54
+ if (picked.length < max)
55
+ picked.push(url);
36
56
  return picked;
37
57
  }
38
58
  const isHtml = (f) => f.status === 200 && /html/i.test(f.headers.get("content-type") ?? "html");
@@ -91,6 +111,6 @@ export async function crawl(targetInput, baseInput, opts) {
91
111
  const probes = await pool(["/rss.xml", "/feed.xml", "/feed", "/atom.xml", "/index.xml"], conc, (p) => fetchUrl(`${target}${p}`, o, { accept: "application/rss+xml,application/atom+xml,application/xml" }));
92
112
  undeclaredFeed = probes.find((f) => f.status === 200 && /<(rss|feed)\b/i.test(f.body.slice(0, 2000)))?.publicUrl ?? null;
93
113
  }
94
- return { target, base, robots, sitemap, pages, llms: llms.status === 200 ? llms : null, llmsFull: llmsFull.status === 200 ? llmsFull : null, feeds, asAgents, llmsLinks, undeclaredFeed };
114
+ return { target, base, robots, sitemap, pages, llms: llms.status === 200 ? llms : null, llmsAttempt: llms, llmsFull: llmsFull.status === 200 ? llmsFull : null, feeds, asAgents, llmsLinks, undeclaredFeed };
95
115
  }
96
116
  const p0 = (pages, base) => pages[0]?.fetched.publicUrl ?? base;
package/dist/i18n/en.d.ts CHANGED
@@ -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",
@@ -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
- const findings = active.flatMap((r) => r.run(site, config, t.m));
14
+ // A check that cannot run here is reported as «not checked» with the reason, never as passed.
15
+ const skipped = new Map(active.map((r) => [r.id, r.applies?.(site, t.m) ?? null]));
16
+ const findings = active.filter((r) => !skipped.get(r.id)).flatMap((r) => r.run(site, config, t.m));
15
17
  return {
16
18
  site: site.base,
17
19
  lang: config.lang,
@@ -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")] : "✓";
@@ -81,6 +81,9 @@ export const accessRules = [
81
81
  id: "A-5",
82
82
  group: "A",
83
83
  level: "info",
84
+ applies(site, t) {
85
+ return site.feeds.length || site.undeclaredFeed ? null : t.skipNoFeed;
86
+ },
84
87
  run(site, _config, t) {
85
88
  const { out, add } = finder(this);
86
89
  if (site.undeclaredFeed)
@@ -13,11 +13,16 @@ const TO_AI = [
13
13
  /игнорируй(те)? (все )?(предыдущие|прошлые) инструкции/iu,
14
14
  ];
15
15
  const datePart = (s) => s.slice(0, 10);
16
+ /** llms.txt that answered 200 with text, not an HTML page served at that address (a common SPA fallback). */
17
+ const llmsReadable = (site) => Boolean(site.llms && !/^\s*<(!doctype|html)/i.test(site.llms.body));
16
18
  export const contentRules = [
17
19
  {
18
20
  id: "D-2",
19
21
  group: "D",
20
22
  level: "warn",
23
+ applies(site, t) {
24
+ return site.pages.some(isArticle) ? null : t.skipNoArticles;
25
+ },
21
26
  run(site, _config, t) {
22
27
  const { out, add } = finder(this);
23
28
  for (const page of site.pages) {
@@ -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
- add("site", t.notFound("/llms.txt"));
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)
@@ -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.2",
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",