aeo-ready 1.7.0 → 1.7.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aeo-ready",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "description": "AEO benchmark aggregator. One scan, every score. Collects agentic-seo, Cloudflare, Fern, Vercel, and AgentGrade in one report.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,6 +40,7 @@
40
40
  "src/index.js",
41
41
  "src/scan.js",
42
42
  "src/fix.js",
43
+ "src/recommendations.js",
43
44
  "skills/",
44
45
  "README.md",
45
46
  "CHANGELOG.md"
@@ -0,0 +1,406 @@
1
+ import chalk from "chalk";
2
+ import { execSync } from "child_process";
3
+ import { existsSync } from "fs";
4
+ import { join } from "path";
5
+ import { createInterface } from "readline";
6
+ import { runFixes } from "./fix.js";
7
+
8
+ const BENCHMARK_NAMES = {
9
+ cloudflare: "Cloudflare",
10
+ fern: "Fern",
11
+ vercel: "Vercel",
12
+ agentgrade: "AgentGrade",
13
+ };
14
+
15
+ const RECOMMENDATION_MAP = [
16
+ {
17
+ key: "robots-ai-rules",
18
+ label: "Allow AI bots in robots.txt",
19
+ detail:
20
+ "Add User-agent / Allow rules for GPTBot, ClaudeBot, and other AI crawlers",
21
+ match: (id) => /^robots\.txt$|robots.*blocked/i.test(id),
22
+ },
23
+ {
24
+ key: "llms-txt",
25
+ label: "Create and link llms.txt",
26
+ detail:
27
+ 'Create llms.txt with site overview, then add <link rel="llms-txt" href="/llms.txt"> to your HTML <head>',
28
+ match: (id) =>
29
+ /llms-txt|llms.*linked|llms-full.*linked|llms.*coverage/i.test(id),
30
+ },
31
+ {
32
+ key: "agents-txt",
33
+ label: "Create agents.txt",
34
+ detail:
35
+ "Add an agents.txt file with agent permissions (User-agent / Allow rules)",
36
+ match: (id) => /agents\.txt/i.test(id),
37
+ },
38
+ {
39
+ key: "content-negotiation",
40
+ label: "Support content negotiation",
41
+ detail:
42
+ "Return markdown when requests include Accept: text/markdown. Add Vary: Accept header for proper caching.",
43
+ match: (id) =>
44
+ /content.negotiation|agent ua.*markdown|accept.*markdown|accept.*text.*returns|accept.*json.*returns|preferred content.type|^vary/i.test(
45
+ id,
46
+ ),
47
+ },
48
+ {
49
+ key: "md-urls",
50
+ label: "Serve markdown at .md URLs",
51
+ detail:
52
+ "Make pages available at .md extensions (e.g. /docs/page.md returns markdown)",
53
+ match: (id) =>
54
+ /markdown.url.support|\.md.*url.*markdown|\.md url/i.test(id),
55
+ },
56
+ {
57
+ key: "content-structure",
58
+ label: "Improve content structure for agents",
59
+ detail:
60
+ "Move nav/chrome below main content so agents find content earlier. Add frontmatter to markdown pages.",
61
+ match: (id) => /content.start.position|frontmatter/i.test(id),
62
+ },
63
+ {
64
+ key: "markdown-parity",
65
+ label: "Ensure markdown/HTML content parity",
66
+ detail:
67
+ "Markdown versions should match HTML content — check tabbed/accordion sections that may be missing",
68
+ match: (id) => /markdown.content.parity/i.test(id),
69
+ },
70
+ {
71
+ key: "sitemap-md",
72
+ label: "Generate sitemap.md",
73
+ detail:
74
+ "Create a markdown sitemap alongside sitemap.xml for agent discovery",
75
+ match: (id) => /^sitemap\.md$/i.test(id),
76
+ },
77
+ {
78
+ key: "redirect",
79
+ label: "Avoid cross-host redirects",
80
+ detail:
81
+ "AI agents may not follow redirects — serve content at the canonical URL",
82
+ match: (id) => /redirect behavior/i.test(id),
83
+ },
84
+ {
85
+ key: "json-ld",
86
+ label: "Add Organization JSON-LD",
87
+ detail: "Add structured data to <head> — see schema.org/Organization",
88
+ match: (id) => /organization.*json.ld|json.ld.*organization/i.test(id),
89
+ },
90
+ {
91
+ key: "identity",
92
+ label: "Add identity and discovery protocols",
93
+ detail:
94
+ "Implement WebFinger, DID Document, A2A Agent Card, and/or WebMCP manifest for agent discovery",
95
+ match: (id) =>
96
+ /webfinger|did document|nostr|at protocol|agent card|webmcp|apple app links|android asset links/i.test(
97
+ id,
98
+ ),
99
+ },
100
+ {
101
+ key: "payment-info",
102
+ label: "Declare payment information",
103
+ detail:
104
+ "Add x-payment-info header to paid API operations for agent billing awareness",
105
+ match: (id) => /x-payment-info/i.test(id),
106
+ },
107
+ {
108
+ key: "skill-md",
109
+ label: "Add skill.md reference",
110
+ detail:
111
+ "Create a skill.md file describing your API's capabilities for agent consumption",
112
+ match: (id) => /skill\.md/i.test(id),
113
+ },
114
+ {
115
+ key: "rate-limits",
116
+ label: "Return rate limit headers",
117
+ detail:
118
+ "Add X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset headers to API responses",
119
+ match: (id) => /rate limit/i.test(id),
120
+ },
121
+ {
122
+ key: "signatures",
123
+ label: "Publish signatures directory",
124
+ detail:
125
+ "Serve /.well-known/http-message-signatures-directory for request verification",
126
+ match: (id) => /signatures directory|public keys/i.test(id),
127
+ },
128
+ {
129
+ key: "members",
130
+ label: "Declare team members",
131
+ detail: "Add a members array to your signatures directory or agent card",
132
+ match: (id) => /members declared/i.test(id),
133
+ },
134
+ {
135
+ key: "form-annotations",
136
+ label: "Add form tool annotations",
137
+ detail:
138
+ "Add tool-name and tool-description attributes to forms for agent understanding",
139
+ match: (id) => /form tool annotations/i.test(id),
140
+ },
141
+ {
142
+ key: "links-resolve",
143
+ label: "Fix broken links in llms.txt",
144
+ detail:
145
+ "Some links in llms.txt point to pages that return errors or unexpected content types",
146
+ match: (id) => /llms-txt-links/i.test(id),
147
+ },
148
+ ];
149
+
150
+ function collectFailedChecks(benchmarks) {
151
+ const failed = [];
152
+ for (const key of Object.keys(BENCHMARK_NAMES)) {
153
+ const b = benchmarks[key];
154
+ if (!b?.available || !b.checks) continue;
155
+ for (const check of b.checks) {
156
+ if (check.status === "fail" || check.status === "warn") {
157
+ failed.push({ ...check, benchmark: key });
158
+ }
159
+ }
160
+ }
161
+ return failed;
162
+ }
163
+
164
+ function buildRecommendations(result) {
165
+ const failed = collectFailedChecks(result.benchmarks);
166
+ const groups = new Map();
167
+
168
+ for (const check of failed) {
169
+ let matched = false;
170
+ for (const rec of RECOMMENDATION_MAP) {
171
+ if (rec.match(check.id)) {
172
+ if (!groups.has(rec.key)) {
173
+ groups.set(rec.key, {
174
+ key: rec.key,
175
+ label: rec.label,
176
+ detail: rec.detail,
177
+ benchmarks: new Set(),
178
+ checks: [],
179
+ });
180
+ }
181
+ const g = groups.get(rec.key);
182
+ g.benchmarks.add(check.benchmark);
183
+ g.checks.push(check);
184
+ matched = true;
185
+ break;
186
+ }
187
+ }
188
+ if (!matched) {
189
+ const fallbackKey = `other-${check.id}`;
190
+ if (!groups.has(fallbackKey)) {
191
+ groups.set(fallbackKey, {
192
+ key: fallbackKey,
193
+ label: check.label || check.id,
194
+ detail: check.description || "",
195
+ benchmarks: new Set(),
196
+ checks: [],
197
+ });
198
+ }
199
+ const g = groups.get(fallbackKey);
200
+ g.benchmarks.add(check.benchmark);
201
+ g.checks.push(check);
202
+ }
203
+ }
204
+
205
+ const recs = [...groups.values()].map((g) => ({
206
+ ...g,
207
+ benchmarks: [...g.benchmarks],
208
+ priority: g.benchmarks.size,
209
+ }));
210
+
211
+ recs.sort((a, b) => b.priority - a.priority);
212
+
213
+ return recs;
214
+ }
215
+
216
+ function tierLabel(count) {
217
+ if (count >= 3) return "High priority";
218
+ if (count >= 2) return "Medium priority";
219
+ return "Lower priority";
220
+ }
221
+
222
+ function tierDescription(count) {
223
+ if (count >= 3) return "flagged by 3+ benchmarks";
224
+ if (count >= 2) return "flagged by 2 benchmarks";
225
+ return "flagged by 1 benchmark";
226
+ }
227
+
228
+ function printRecommendations(recs) {
229
+ let currentTier = null;
230
+ let num = 1;
231
+
232
+ console.log("");
233
+ for (const rec of recs) {
234
+ const tier = tierLabel(rec.priority);
235
+ if (tier !== currentTier) {
236
+ currentTier = tier;
237
+ const color =
238
+ rec.priority >= 3
239
+ ? chalk.red
240
+ : rec.priority >= 2
241
+ ? chalk.yellow
242
+ : chalk.dim;
243
+ console.log(
244
+ ` ${color.bold(tier)} ${chalk.dim(`(${tierDescription(rec.priority)})`)}`,
245
+ );
246
+ }
247
+ const benchmarkList = rec.benchmarks
248
+ .map((b) => BENCHMARK_NAMES[b])
249
+ .join(" · ");
250
+ console.log(` ${chalk.dim(`${num}.`)} ${rec.label}`);
251
+ console.log(` ${chalk.dim(benchmarkList)}`);
252
+ num++;
253
+ }
254
+ console.log("");
255
+ }
256
+
257
+ function generateAgentPrompt(result, recs) {
258
+ const lines = [];
259
+ lines.push(
260
+ `My site ${result.url} scored ${result.averageScore}/100 on aeo-ready (AEO readiness scanner).`,
261
+ );
262
+ lines.push(`Fix these issues to improve AI/agent discoverability:\n`);
263
+
264
+ let currentTier = null;
265
+ let num = 1;
266
+
267
+ for (const rec of recs) {
268
+ const tier = tierLabel(rec.priority);
269
+ if (tier !== currentTier) {
270
+ currentTier = tier;
271
+ lines.push(`## ${tier} (${tierDescription(rec.priority)})`);
272
+ }
273
+ const benchmarkList = rec.benchmarks
274
+ .map((b) => BENCHMARK_NAMES[b])
275
+ .join(", ");
276
+ lines.push(`${num}. ${rec.label} [${benchmarkList}]`);
277
+ if (rec.detail) {
278
+ lines.push(` ${rec.detail}`);
279
+ }
280
+ num++;
281
+ }
282
+
283
+ lines.push("");
284
+ lines.push(
285
+ "For any issues that can't be fixed programmatically, outline them for me",
286
+ );
287
+ lines.push(
288
+ "with clear step-by-step instructions on how to address them manually.",
289
+ );
290
+ lines.push("");
291
+ lines.push(`Re-scan after: npx aeo-ready scan ${result.url}`);
292
+
293
+ return lines.join("\n");
294
+ }
295
+
296
+ function copyToClipboard(text) {
297
+ try {
298
+ execSync("pbcopy", { input: text, stdio: ["pipe", "pipe", "pipe"] });
299
+ return true;
300
+ } catch {
301
+ try {
302
+ execSync("xclip -selection clipboard", {
303
+ input: text,
304
+ stdio: ["pipe", "pipe", "pipe"],
305
+ });
306
+ return true;
307
+ } catch {
308
+ try {
309
+ execSync("xsel --clipboard --input", {
310
+ input: text,
311
+ stdio: ["pipe", "pipe", "pipe"],
312
+ });
313
+ return true;
314
+ } catch {
315
+ return false;
316
+ }
317
+ }
318
+ }
319
+ }
320
+
321
+ function detectLocalProject(dir) {
322
+ if (dir) return dir;
323
+ const cwd = process.cwd();
324
+ const indicators = [
325
+ "package.json",
326
+ "index.html",
327
+ "next.config.js",
328
+ "next.config.mjs",
329
+ "next.config.ts",
330
+ "nuxt.config.ts",
331
+ "astro.config.mjs",
332
+ "vite.config.ts",
333
+ "vite.config.js",
334
+ "gatsby-config.js",
335
+ "angular.json",
336
+ "svelte.config.js",
337
+ "remix.config.js",
338
+ "public",
339
+ ];
340
+ for (const f of indicators) {
341
+ if (existsSync(join(cwd, f))) return cwd;
342
+ }
343
+ return null;
344
+ }
345
+
346
+ function ask(question) {
347
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
348
+ return new Promise((resolve) => {
349
+ rl.question(question, (answer) => {
350
+ rl.close();
351
+ resolve(answer.trim().toLowerCase());
352
+ });
353
+ });
354
+ }
355
+
356
+ export async function showRecommendations(result, dir) {
357
+ const recs = buildRecommendations(result);
358
+ if (recs.length === 0) return;
359
+
360
+ const highCount = recs.filter((r) => r.priority >= 3).length;
361
+ const summary =
362
+ highCount > 0
363
+ ? `${recs.length} recommendations (${highCount} high priority)`
364
+ : `${recs.length} recommendations`;
365
+
366
+ console.log(`\n ${chalk.bold(summary)}\n`);
367
+
368
+ const localDir = detectLocalProject(dir);
369
+
370
+ const options = [];
371
+ options.push(["v", "View recommendations"]);
372
+ options.push(["c", "Copy prompt for AI agent"]);
373
+ if (localDir) {
374
+ options.push(["f", "Fix now"]);
375
+ }
376
+ options.push(["q", "Done"]);
377
+
378
+ const optStr = options
379
+ .map(([key, label]) => `${chalk.bold(`[${key}]`)} ${label}`)
380
+ .join(" ");
381
+
382
+ while (true) {
383
+ const answer = await ask(` ${optStr} `);
384
+
385
+ if (answer === "v") {
386
+ printRecommendations(recs);
387
+ } else if (answer === "c") {
388
+ const prompt = generateAgentPrompt(result, recs);
389
+ const copied = copyToClipboard(prompt);
390
+ if (copied) {
391
+ console.log(chalk.green("\n Copied to clipboard.\n"));
392
+ } else {
393
+ console.log(
394
+ chalk.dim("\n Could not copy — here are the instructions:\n"),
395
+ );
396
+ console.log(prompt);
397
+ console.log("");
398
+ }
399
+ } else if (answer === "f" && localDir) {
400
+ await runFixes(result, localDir);
401
+ break;
402
+ } else {
403
+ break;
404
+ }
405
+ }
406
+ }