aeo-ready 1.1.0 → 1.3.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/src/scan.js CHANGED
@@ -1,65 +1,38 @@
1
1
  import chalk from "chalk";
2
- import { fetchUrl, resolveUrl } from "./utils/fetch.js";
3
- import { detectSiteType } from "./utils/detect-type.js";
4
- import { runDiscoveryChecks } from "./checks/agent-readiness/discovery.js";
5
- import { runContentStructureChecks } from "./checks/agent-readiness/content-structure.js";
6
- import { runCapabilityChecks } from "./checks/agent-readiness/capability.js";
7
- import { runActionableChecks } from "./checks/agent-readiness/actionable.js";
8
- import { runStructuredDataChecks } from "./checks/ai-visibility/structured-data.js";
9
- import { runCitationReadinessChecks } from "./checks/ai-visibility/citation-readiness.js";
10
- import { runAuthorityChecks } from "./checks/ai-visibility/authority.js";
11
- import { runFreshnessChecks } from "./checks/ai-visibility/freshness.js";
12
2
  import { runAllBenchmarks, printBenchmarks } from "./benchmark/index.js";
13
3
  import { saveResult } from "./history/index.js";
14
4
  import { generateDashboard } from "./dashboard/generate.js";
15
- import { readdirSync, readFileSync, existsSync } from "fs";
16
- import { join } from "path";
17
5
  import { exec } from "child_process";
18
6
 
19
7
  export async function scan(opts) {
20
- const { url, dir, json, benchmark } = opts;
21
- const mode = url ? "url" : "repo";
8
+ const { url, dir, json } = opts;
22
9
 
23
10
  if (!json) {
24
11
  console.log(
25
- chalk.bold("\n aeo-ready") + chalk.dim(" — AI readiness audit\n"),
12
+ chalk.bold("\n aeo-ready") + chalk.dim(" — AEO benchmark aggregator\n"),
26
13
  );
14
+ console.log(chalk.dim(` Scanning ${url}...\n`));
27
15
  }
28
16
 
29
- const context = await gatherContext(mode, url, dir);
30
-
31
- if (!json) {
32
- console.log(chalk.dim(` Mode: ${mode} | Type: ${context.siteType}\n`));
33
- }
34
-
35
- const agentReadiness = await runAgentReadinessChecks(context);
36
- const aiVisibility = await runAiVisibilityChecks(context);
37
-
38
- const score = agentReadiness.score + aiVisibility.score;
39
- const grade = scoreToGrade(score);
40
-
41
- let benchmarkResult = null;
42
- if (benchmark) {
43
- benchmarkResult = await runAllBenchmarks(mode === "url" ? url : dir);
44
- }
17
+ const benchmarks = await runAllBenchmarks(url, dir);
18
+ const scores = collectScores(benchmarks);
19
+ const averageScore =
20
+ scores.length > 0
21
+ ? Math.round(scores.reduce((s, v) => s + v, 0) / scores.length)
22
+ : 0;
45
23
 
46
24
  const result = {
47
- score,
48
- grade,
49
- siteType: context.siteType,
50
- mode,
51
- target: url || dir,
25
+ url,
52
26
  timestamp: new Date().toISOString(),
53
- agentReadiness,
54
- aiVisibility,
55
- benchmarks: benchmarkResult,
27
+ averageScore,
28
+ benchmarks,
56
29
  };
57
30
 
58
31
  if (!json) {
59
32
  printReport(result);
60
33
  }
61
34
 
62
- const baseDir = dir || process.cwd();
35
+ const baseDir = process.cwd();
63
36
  await saveResult(result, baseDir);
64
37
 
65
38
  if (!json) {
@@ -71,278 +44,71 @@ export async function scan(opts) {
71
44
  return result;
72
45
  }
73
46
 
74
- async function gatherContext(mode, url, dir) {
75
- const context = { mode, url, dir, pages: {}, files: [], html: "" };
76
-
77
- if (mode === "url") {
78
- const homepage = await fetchUrl(url);
79
- context.html = homepage.text;
80
- context.pages.home = homepage;
81
-
82
- const fetches = [
83
- fetchUrl(resolveUrl(url, "/llms.txt")),
84
- fetchUrl(resolveUrl(url, "/robots.txt")),
85
- fetchUrl(resolveUrl(url, "/sitemap.xml")),
86
- fetchUrl(resolveUrl(url, "/.well-known/agent.json")),
87
- fetchUrl(resolveUrl(url, "/openapi.json")),
88
- ];
89
-
90
- const [llms, robots, sitemap, agentJson, openapi] =
91
- await Promise.all(fetches);
92
- context.pages.llmsTxt = llms;
93
- context.pages.robotsTxt = robots;
94
- context.pages.sitemap = sitemap;
95
- context.pages.agentJson = agentJson;
96
- context.pages.openapi = openapi;
97
- } else {
98
- const fileList = listFiles(dir);
99
- context.files = fileList;
100
- context.fileContents = {};
101
- for (const f of fileList) {
102
- try {
103
- context.fileContents[f] = readFileSync(join(dir, f), "utf8");
104
- } catch {
105
- /* skip binary/unreadable */
106
- }
107
- }
108
- const indexHtml = [
109
- "index.html",
110
- "public/index.html",
111
- "dist/index.html",
112
- ].find((p) => existsSync(join(dir, p)));
113
- if (indexHtml) {
114
- context.html = readFileSync(join(dir, indexHtml), "utf8");
115
- }
47
+ function collectScores(benchmarks) {
48
+ const scores = [];
49
+ if (benchmarks.agenticSeo?.available) {
50
+ scores.push(benchmarks.agenticSeo.score);
116
51
  }
117
-
118
- context.siteType = detectSiteType({
119
- html: context.html,
120
- files: context.files,
121
- url,
122
- });
123
-
124
- return context;
125
- }
126
-
127
- function listFiles(dir) {
128
- const skip = new Set([
129
- "node_modules",
130
- ".git",
131
- "dist",
132
- "build",
133
- ".next",
134
- ".venv",
135
- "__pycache__",
136
- "target",
137
- ".aeo-ready",
138
- ]);
139
- const results = [];
140
-
141
- function walk(current, prefix) {
142
- let entries;
143
- try {
144
- entries = readdirSync(join(dir, current), { withFileTypes: true });
145
- } catch {
146
- return;
147
- }
148
- for (const entry of entries) {
149
- if (skip.has(entry.name)) continue;
150
- const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
151
- if (entry.isDirectory()) {
152
- walk(join(current, entry.name), rel);
153
- } else {
154
- results.push(rel);
155
- }
156
- }
52
+ if (benchmarks.cloudflare?.available) {
53
+ scores.push(
54
+ Math.round(
55
+ (benchmarks.cloudflare.score / benchmarks.cloudflare.maxScore) * 100,
56
+ ),
57
+ );
157
58
  }
158
-
159
- walk("", "");
160
- return results;
161
- }
162
-
163
- async function runAgentReadinessChecks(context) {
164
- const discovery = await runDiscoveryChecks(context);
165
- const contentStructure = await runContentStructureChecks(context);
166
- const capability = await runCapabilityChecks(context);
167
- const actionable = await runActionableChecks(context);
168
-
169
- const score =
170
- discovery.score +
171
- contentStructure.score +
172
- capability.score +
173
- actionable.score;
174
-
175
- return {
176
- score,
177
- maxScore: 50,
178
- categories: { discovery, contentStructure, capability, actionable },
179
- };
180
- }
181
-
182
- async function runAiVisibilityChecks(context) {
183
- const structuredData = await runStructuredDataChecks(context);
184
- const citationReadiness = await runCitationReadinessChecks(context);
185
- const authority = await runAuthorityChecks(context);
186
- const freshness = await runFreshnessChecks(context);
187
-
188
- const score =
189
- structuredData.score +
190
- citationReadiness.score +
191
- authority.score +
192
- freshness.score;
193
-
194
- return {
195
- score,
196
- maxScore: 50,
197
- categories: { structuredData, citationReadiness, authority, freshness },
198
- };
199
- }
200
-
201
- function scoreToGrade(score) {
202
- if (score >= 80) return "A";
203
- if (score >= 65) return "B";
204
- if (score >= 50) return "C";
205
- if (score >= 35) return "D";
206
- return "F";
59
+ if (benchmarks.fern?.available) {
60
+ scores.push(benchmarks.fern.score);
61
+ }
62
+ return scores;
207
63
  }
208
64
 
209
65
  function printReport(result) {
210
- const { score, grade, agentReadiness, aiVisibility, benchmarks } = result;
66
+ const { benchmarks, averageScore } = result;
211
67
 
212
- const gradeColor =
213
- grade === "A"
214
- ? chalk.green
215
- : grade === "B"
216
- ? chalk.cyan
217
- : grade === "C"
218
- ? chalk.yellow
219
- : chalk.red;
220
-
221
- console.log(
222
- ` ${bar(score, 100, 40)} ${gradeColor.bold(grade)} ${score}/100\n`,
223
- );
68
+ printBenchmarks(benchmarks);
224
69
 
70
+ const gc =
71
+ averageScore >= 80
72
+ ? chalk.green
73
+ : averageScore >= 50
74
+ ? chalk.yellow
75
+ : chalk.red;
225
76
  console.log(
226
- chalk.dim(
227
- " Agent Readiness — can AI agents find, read, and use your site?",
228
- ),
77
+ chalk.dim(" ─────────────────────────────────────────────────\n"),
229
78
  );
230
79
  console.log(
231
- chalk.dim(
232
- " AI Visibility — how accurately do AI search engines describe you?",
233
- ),
80
+ ` Average across all sources: ${gc.bold(`${averageScore}/100`)}\n`,
234
81
  );
235
82
 
236
- if (agentReadiness.score < 25) {
83
+ if (averageScore < 80) {
84
+ console.log(chalk.bold(" Fix it:\n"));
237
85
  console.log(
238
- chalk.dim(
239
- "\n Agent Readiness is low — AI engines can't cite what they can't read.",
240
- ),
86
+ chalk.dim(" npx agentic-seo init") +
87
+ " scaffold llms.txt, AGENTS.md, skill.md",
241
88
  );
242
- console.log(chalk.dim(" Fix the agent side first. Visibility follows."));
243
- }
244
- console.log("");
245
89
 
246
- printScorecard("Agent Readiness", agentReadiness);
247
- printScorecard("AI Visibility", aiVisibility);
248
-
249
- const allChecks = [
250
- ...Object.values(agentReadiness.categories),
251
- ...Object.values(aiVisibility.categories),
252
- ].flatMap((c) => c.checks);
253
- const passed = allChecks.filter((c) => c.passed).length;
254
- const failed = allChecks.filter((c) => !c.passed && c.points === 0).length;
255
- const partial = allChecks.length - passed - failed;
256
-
257
- console.log(
258
- chalk.dim(
259
- ` ${chalk.green(passed + " passed")} · ${partial ? chalk.yellow(partial + " partial") + " · " : ""}${chalk.red(failed + " failed")} · ${allChecks.length} checks total\n`,
260
- ),
261
- );
262
-
263
- if (benchmarks) {
264
- printBenchmarks(benchmarks);
265
- }
266
-
267
- if (failed + partial > 0) {
268
- console.log(chalk.bold(" Next steps:\n"));
269
- const topFixes = allChecks
270
- .filter((c) => !c.passed && c.fix)
271
- .sort(
272
- (a, b) =>
273
- b.maxPoints - (b.points || 0) - (a.maxPoints - (a.points || 0)),
274
- )
275
- .slice(0, 3);
276
- for (const fix of topFixes) {
277
- console.log(chalk.dim(` - ${fix.fix}`));
90
+ const cfFails =
91
+ benchmarks.cloudflare?.checks?.filter((c) => c.status === "fail") || [];
92
+ for (const fail of cfFails.slice(0, 2)) {
93
+ console.log(
94
+ chalk.dim(` Cloudflare: ${fail.id}`) +
95
+ chalk.dim(` — see isitagentready.com for fix`),
96
+ );
278
97
  }
279
- console.log(
280
- chalk.dim(
281
- `\n Run ${chalk.bold("npx aeo-ready scan --fix")} to auto-fix what we can.`,
282
- ),
283
- );
284
- console.log(
285
- chalk.dim(
286
- " Open the dashboard for step-by-step guides on everything else.\n",
287
- ),
288
- );
289
- }
290
- }
291
-
292
- function printScorecard(name, scorecard) {
293
- const pct = Math.round((scorecard.score / scorecard.maxScore) * 100);
294
- console.log(
295
- chalk.bold(` ${name}`) +
296
- chalk.dim(` ${scorecard.score}/${scorecard.maxScore} (${pct}%)`),
297
- );
298
98
 
299
- for (const [catName, cat] of Object.entries(scorecard.categories)) {
300
- const label = formatCategoryName(catName);
301
- const catPct = Math.round((cat.score / cat.maxScore) * 100);
302
- const icon =
303
- catPct >= 80
304
- ? chalk.green("✓")
305
- : catPct >= 40
306
- ? chalk.yellow("◑")
307
- : chalk.red("✗");
308
- console.log(
309
- ` ${icon} ${label.padEnd(28)} ${bar(cat.score, cat.maxScore, 20)} ${chalk.dim(`${cat.score}/${cat.maxScore} (${catPct}%)`)}`,
310
- );
311
-
312
- for (const check of cat.checks) {
313
- if (check.passed) {
314
- console.log(
315
- chalk.green(` +`) +
316
- chalk.dim(` ${check.name} [${check.points}]`),
317
- );
318
- } else {
319
- console.log(
320
- chalk.red(` -`) +
321
- ` ${check.name} ` +
322
- chalk.dim(`[${check.points || 0}/${check.maxPoints}]`),
323
- );
324
- if (check.fix) {
325
- console.log(chalk.dim(` ${check.fix}`));
326
- }
327
- }
99
+ const fernFails =
100
+ benchmarks.fern?.checks?.filter(
101
+ (c) => c.status === "fail" || c.status === "warn",
102
+ ) || [];
103
+ if (fernFails.length > 0) {
104
+ console.log(
105
+ chalk.dim(` Fern: ${fernFails.length} issues`) +
106
+ chalk.dim(` — run npx afdocs ${result.url}`),
107
+ );
328
108
  }
329
- }
330
- console.log("");
331
- }
332
-
333
- function bar(value, max, width) {
334
- const filled = max > 0 ? Math.round((value / max) * width) : 0;
335
- const empty = width - filled;
336
- const pct = max > 0 ? Math.round((value / max) * 100) : 0;
337
- const color = pct >= 80 ? chalk.green : pct >= 50 ? chalk.yellow : chalk.red;
338
- return color("█".repeat(filled)) + chalk.dim("░".repeat(empty));
339
- }
340
109
 
341
- function formatCategoryName(camelCase) {
342
- return camelCase
343
- .replace(/([A-Z])/g, " $1")
344
- .replace(/^./, (c) => c.toUpperCase())
345
- .trim();
110
+ console.log(chalk.dim("\n Fix, then re-scan to track improvement.\n"));
111
+ }
346
112
  }
347
113
 
348
114
  function openInBrowser(filePath) {
@@ -1,165 +0,0 @@
1
- export async function runActionableChecks(context) {
2
- const checks = [
3
- checkContact(context),
4
- checkPricing(context),
5
- checkApiEndpoint(context),
6
- checkSdkManifest(context),
7
- ];
8
-
9
- const score = checks.reduce((sum, c) => sum + (c.passed ? c.points : 0), 0);
10
- return { score, maxScore: 10, checks };
11
- }
12
-
13
- function checkContact(context) {
14
- const html = context.html || "";
15
- const allContent = getAllContent(context);
16
-
17
- const hasEmail = /[\w.+-]+@[\w-]+\.[\w.]+/.test(allContent);
18
- const hasCalLink = /cal\.com|calendly\.com|hubspot\.com\/meetings/.test(
19
- allContent,
20
- );
21
- const hasStructuredContact =
22
- allContent.includes("ContactPoint") || allContent.includes('"email"');
23
-
24
- let points = 0;
25
- if (hasEmail) points += 1;
26
- if (hasCalLink) points += 1;
27
- if (hasStructuredContact) points += 1;
28
-
29
- if (points === 3) return pass("Machine-readable contact", 3);
30
- if (points > 0) {
31
- const missing = [];
32
- if (!hasEmail) missing.push("email");
33
- if (!hasCalLink) missing.push("booking link");
34
- if (!hasStructuredContact) missing.push("structured ContactPoint");
35
- return partial(
36
- "Machine-readable contact",
37
- points,
38
- 3,
39
- `Add: ${missing.join(", ")}.`,
40
- );
41
- }
42
- return fail(
43
- "Machine-readable contact",
44
- 3,
45
- "No machine-readable contact info. Add email, cal.com link, or ContactPoint schema.",
46
- );
47
- }
48
-
49
- function checkPricing(context) {
50
- if (context.siteType === "personal" || context.siteType === "content") {
51
- return {
52
- name: "Programmatic pricing",
53
- passed: true,
54
- points: 3,
55
- maxPoints: 3,
56
- note: "N/A for site type",
57
- };
58
- }
59
-
60
- const allContent = getAllContent(context);
61
- const files = context.files || [];
62
-
63
- const hasPricingJson = files.some((f) => f.includes("pricing.json"));
64
- const hasPriceSchema =
65
- allContent.includes("PriceSpecification") || allContent.includes("offers");
66
- const hasPricingPage =
67
- allContent.includes("/pricing") || allContent.includes("pricing");
68
-
69
- if (hasPricingJson || hasPriceSchema) return pass("Programmatic pricing", 3);
70
- if (hasPricingPage)
71
- return partial(
72
- "Programmatic pricing",
73
- 1,
74
- 3,
75
- "Pricing page exists but not in structured format. Add schema.org PriceSpecification or pricing.json.",
76
- );
77
- return fail(
78
- "Programmatic pricing",
79
- 3,
80
- "No programmatic pricing. Agents comparing you to competitors need structured plan data.",
81
- );
82
- }
83
-
84
- function checkApiEndpoint(context) {
85
- if (context.siteType === "personal") {
86
- return {
87
- name: "API endpoint",
88
- passed: true,
89
- points: 2,
90
- maxPoints: 2,
91
- note: "N/A for site type",
92
- };
93
- }
94
-
95
- const allContent = getAllContent(context);
96
- const hasApiRef = /\/api\/|\/v[12]\/|api-key|apikey/i.test(allContent);
97
- const hasOpenApi =
98
- context.files?.some((f) => f.includes("openapi")) ||
99
- context.pages?.openapi?.status === 200;
100
-
101
- if (hasApiRef && hasOpenApi) return pass("API endpoint", 2);
102
- if (hasApiRef)
103
- return partial(
104
- "API endpoint",
105
- 1,
106
- 2,
107
- "API references found but no formal spec. Add OpenAPI spec.",
108
- );
109
- return fail("API endpoint", 2, "No API endpoint detected.");
110
- }
111
-
112
- function checkSdkManifest(context) {
113
- if (context.siteType === "personal" || context.siteType === "content") {
114
- return {
115
- name: "SDK / integration manifest",
116
- passed: true,
117
- points: 2,
118
- maxPoints: 2,
119
- note: "N/A for site type",
120
- };
121
- }
122
-
123
- const allContent = getAllContent(context);
124
- const hasSdk = /npm install|pip install|go get|cargo add|maven|gradle/i.test(
125
- allContent,
126
- );
127
- const hasIntegrations = /integrat|connect|plugin|extension|mcp/i.test(
128
- allContent,
129
- );
130
-
131
- if (hasSdk) return pass("SDK / integration manifest", 2);
132
- if (hasIntegrations)
133
- return partial(
134
- "SDK / integration manifest",
135
- 1,
136
- 2,
137
- "Integration mentions found but no install commands or SDK manifest.",
138
- );
139
- return fail(
140
- "SDK / integration manifest",
141
- 2,
142
- "No SDK or integration manifest detected.",
143
- );
144
- }
145
-
146
- function getAllContent(context) {
147
- if (context.mode === "url") {
148
- return Object.values(context.pages || {})
149
- .map((p) => p?.text || "")
150
- .join("\n");
151
- }
152
- return Object.values(context.fileContents || {}).join("\n");
153
- }
154
-
155
- function pass(name, points) {
156
- return { name, passed: true, points, maxPoints: points };
157
- }
158
-
159
- function partial(name, points, maxPoints, fix) {
160
- return { name, passed: false, points, maxPoints, fix };
161
- }
162
-
163
- function fail(name, maxPoints, fix) {
164
- return { name, passed: false, points: 0, maxPoints, fix };
165
- }