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