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/README.md +52 -76
- package/bin/cli.js +20 -62
- package/package.json +2 -2
- package/src/benchmark/agentic-seo.js +107 -6
- package/src/benchmark/index.js +2 -2
- package/src/dashboard/generate.js +22 -48
- package/src/dashboard/sections/benchmark-details.js +79 -0
- package/src/dashboard/sections/history-table.js +10 -7
- package/src/dashboard/sections/overall-score.js +55 -118
- package/src/dashboard/sections/trend-chart.js +31 -46
- package/src/history/index.js +8 -15
- package/src/scan.js +59 -293
- package/src/checks/agent-readiness/actionable.js +0 -165
- package/src/checks/agent-readiness/capability.js +0 -209
- package/src/checks/agent-readiness/content-structure.js +0 -242
- package/src/checks/agent-readiness/discovery.js +0 -231
- package/src/checks/ai-visibility/authority.js +0 -195
- package/src/checks/ai-visibility/citation-readiness.js +0 -228
- package/src/checks/ai-visibility/freshness.js +0 -182
- package/src/checks/ai-visibility/structured-data.js +0 -180
- package/src/dashboard/sections/agent-readiness.js +0 -71
- package/src/dashboard/sections/ai-visibility.js +0 -67
- package/src/dashboard/sections/recommendations.js +0 -196
- package/src/fix/generators/agents-json.js +0 -73
- package/src/fix/generators/agents-md.js +0 -85
- package/src/fix/generators/llms-txt.js +0 -166
- package/src/fix/generators/robots-txt.js +0 -64
- package/src/fix/index.js +0 -177
- package/src/track/index.js +0 -167
- package/src/utils/detect-type.js +0 -99
- package/src/utils/tokens.js +0 -18
package/README.md
CHANGED
|
@@ -1,115 +1,91 @@
|
|
|
1
|
-
#
|
|
1
|
+
# aeo-ready
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
AEO benchmark aggregator. One scan, every score.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npx
|
|
6
|
+
npx aeo-ready scan yoursite.com
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
## What it
|
|
9
|
+
## What it does
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
- Discovery: llms.txt, robots.txt AI crawlers, sitemap, meta tags
|
|
13
|
-
- Content Structure: markdown availability, heading hierarchy, token budgets, front-loading
|
|
14
|
-
- Capability Signaling: AGENTS.md, agents.json, OpenAPI, content negotiation
|
|
15
|
-
- Actionable: machine-readable contact, pricing, API endpoints, SDK manifest
|
|
11
|
+
Runs every major AEO (Agentic Engine Optimization) benchmark against your site in one command. Shows per-check pass/fail, company comparisons, and tracks scores over time.
|
|
16
12
|
|
|
17
|
-
|
|
18
|
-
- Structured Data: schema.org, FAQ markup, rich schemas, Open Graph
|
|
19
|
-
- Citation Readiness: direct answer formatting, question headings, citable structure
|
|
20
|
-
- Authority: E-E-A-T signals, entity optimization, external validation
|
|
21
|
-
- Freshness: modification dates, publication cadence, content recency
|
|
13
|
+
## Sources
|
|
22
14
|
|
|
23
|
-
|
|
15
|
+
| Benchmark | What it checks | Checks |
|
|
16
|
+
|-----------|---------------|--------|
|
|
17
|
+
| **agentic-seo** (Addy Osmani) | Discovery, content structure, token economics, capability signaling, UX bridge | 10 |
|
|
18
|
+
| **Cloudflare** (isitagentready.com) | Discoverability, content accessibility, bot access, API/auth/MCP/A2A discovery, commerce | 19 |
|
|
19
|
+
| **Fern** (afdocs) | llms.txt quality, markdown availability, page size, content structure, URL stability, auth | 23 |
|
|
24
20
|
|
|
25
21
|
## Usage
|
|
26
22
|
|
|
27
23
|
```bash
|
|
28
|
-
#
|
|
29
|
-
npx
|
|
24
|
+
npx aeo-ready scan yoursite.com # scan a URL (remote checks)
|
|
25
|
+
npx aeo-ready scan yoursite.com --dir ./public # full scan (local + remote)
|
|
26
|
+
npx aeo-ready scan yoursite.com --json # JSON output for CI
|
|
27
|
+
npx aeo-ready scan yoursite.com --threshold 60 # exit 1 if below
|
|
28
|
+
```
|
|
30
29
|
|
|
31
|
-
|
|
32
|
-
npx agent-web scan
|
|
30
|
+
### Why `--dir`?
|
|
33
31
|
|
|
34
|
-
|
|
35
|
-
npx agent-web scan --json --threshold 60
|
|
32
|
+
agentic-seo scores ~23/100 in URL-only mode because most checks (content structure, token economics, capability signaling, UX bridge) need filesystem access. Pass `--dir` to your build output or public directory to get the real score.
|
|
36
33
|
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
```
|
|
35
|
+
URL-only: agentic-seo 23/100 (F)
|
|
36
|
+
With --dir: agentic-seo 92/100 (A)
|
|
39
37
|
```
|
|
40
38
|
|
|
41
39
|
## Output
|
|
42
40
|
|
|
43
41
|
```
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
Mode: url | Type: saas
|
|
42
|
+
aeo-ready — AEO benchmark aggregator
|
|
47
43
|
|
|
48
|
-
|
|
44
|
+
─── Benchmarks ────────────────────────────────────
|
|
49
45
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
46
|
+
███████████████░ agentic-seo 92/100 (A)
|
|
47
|
+
✓ Discovery 25/25
|
|
48
|
+
◑ Content Structure 19/25
|
|
49
|
+
✓ Token Economics 25/25
|
|
50
|
+
✓ Capability Signaling 15/15
|
|
51
|
+
✓ UX Bridge 8/10
|
|
52
|
+
compare: Cloudflare 55 · Supabase 52 · Vercel 48
|
|
57
53
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
+ Rich schemas [3]
|
|
62
|
-
- Schema.org markup [0/4]
|
|
63
|
-
fix: type doesn't match site category.
|
|
64
|
-
|
|
65
|
-
Second opinion: agentic-seo scored you 45/100
|
|
66
|
-
```
|
|
54
|
+
█████████████░░░ Cloudflare 4/5 (B)
|
|
55
|
+
+ robotsTxt, + sitemap, + linkHeaders, + agentSkills...
|
|
56
|
+
compare: Cloudflare 5 · Vercel 4 · Supabase 3
|
|
67
57
|
|
|
68
|
-
|
|
58
|
+
█████████████░░░ Fern 83/100 (B)
|
|
59
|
+
+ llms-txt-exists, + rendering-strategy, - content-negotiation...
|
|
60
|
+
compare: Stripe 85 · Supabase 78 · Anthropic 72
|
|
69
61
|
|
|
70
|
-
|
|
62
|
+
Average across all sources: 85/100
|
|
71
63
|
|
|
72
|
-
|
|
73
|
-
npx
|
|
64
|
+
Fix it:
|
|
65
|
+
npx agentic-seo init scaffold llms.txt, AGENTS.md
|
|
66
|
+
Fern: 6 issues — run npx afdocs https://yoursite.com
|
|
74
67
|
```
|
|
75
68
|
|
|
76
|
-
|
|
69
|
+
## CI Mode
|
|
77
70
|
|
|
78
71
|
```yaml
|
|
79
|
-
- run: npx
|
|
72
|
+
- run: npx aeo-ready scan yoursite.com --dir ./public --threshold 50
|
|
80
73
|
```
|
|
81
74
|
|
|
82
|
-
##
|
|
75
|
+
## Dashboard
|
|
83
76
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
77
|
+
Each scan generates a self-contained HTML dashboard at `.aeo-ready/dashboard.html` with:
|
|
78
|
+
- Score cards for each benchmark
|
|
79
|
+
- Per-check detail (expandable)
|
|
80
|
+
- Company comparisons
|
|
81
|
+
- Score trends over time (inline SVG)
|
|
82
|
+
- Scan history with deltas
|
|
87
83
|
|
|
88
|
-
|
|
84
|
+
Auto-opens in browser after each scan.
|
|
89
85
|
|
|
90
|
-
##
|
|
91
|
-
|
|
92
|
-
Automatically infers site type from signals:
|
|
93
|
-
- **SaaS** — pricing + auth pages detected
|
|
94
|
-
- **API/Developer Tool** — /docs, /api, SDK references
|
|
95
|
-
- **Content/Blog** — articles, blog posts, publication cadence
|
|
96
|
-
- **Personal/Portfolio** — portfolio, about me, single person
|
|
97
|
-
|
|
98
|
-
Scoring adjusts by type (e.g., OpenAPI is required for SaaS, N/A for personal sites).
|
|
99
|
-
|
|
100
|
-
## Coming Soon
|
|
101
|
-
|
|
102
|
-
- `--fix` mode: generate missing files (robots.txt, meta tags, structured data)
|
|
103
|
-
- HTML dashboard with score trends over time
|
|
104
|
-
- Citation correlation: query AI models and track whether fixes improve citations
|
|
105
|
-
|
|
106
|
-
## Also available as a Claude Code skill
|
|
107
|
-
|
|
108
|
-
```bash
|
|
109
|
-
npx skills add katrinalaszlo/agent-web
|
|
110
|
-
```
|
|
86
|
+
## History
|
|
111
87
|
|
|
112
|
-
|
|
88
|
+
Scores persist in `.aeo-ready/history.json`. Re-scan to track improvement over time.
|
|
113
89
|
|
|
114
90
|
## Author
|
|
115
91
|
|
package/bin/cli.js
CHANGED
|
@@ -2,12 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
import { scan } from "../src/scan.js";
|
|
5
|
-
import { fix } from "../src/fix/index.js";
|
|
6
|
-
import {
|
|
7
|
-
track,
|
|
8
|
-
printTrackResults,
|
|
9
|
-
getAvailableProviders,
|
|
10
|
-
} from "../src/track/index.js";
|
|
11
5
|
import { readFileSync } from "fs";
|
|
12
6
|
import { dirname, join } from "path";
|
|
13
7
|
import { fileURLToPath } from "url";
|
|
@@ -21,66 +15,44 @@ const program = new Command();
|
|
|
21
15
|
|
|
22
16
|
program
|
|
23
17
|
.name("aeo-ready")
|
|
24
|
-
.description("
|
|
18
|
+
.description("AEO benchmark aggregator. One scan, every score.")
|
|
25
19
|
.version(pkg.version);
|
|
26
20
|
|
|
27
21
|
program
|
|
28
22
|
.command("scan [url]")
|
|
29
|
-
.description(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
.option(
|
|
33
|
-
|
|
23
|
+
.description(
|
|
24
|
+
"Run all AEO benchmarks against a URL (add --dir for local scanning)",
|
|
25
|
+
)
|
|
26
|
+
.option(
|
|
27
|
+
"-d, --dir <path>",
|
|
28
|
+
"Local directory to scan (gives agentic-seo full access)",
|
|
29
|
+
)
|
|
34
30
|
.option("--json", "Output results as JSON")
|
|
35
31
|
.option(
|
|
36
32
|
"--threshold <number>",
|
|
37
|
-
"Minimum score to pass (exit 1 if below)",
|
|
33
|
+
"Minimum average score to pass (exit 1 if below)",
|
|
38
34
|
parseInt,
|
|
39
35
|
)
|
|
40
|
-
.option("--no-benchmark", "Skip agentic-seo benchmark")
|
|
41
36
|
.action(async (url, opts) => {
|
|
42
37
|
try {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
dir,
|
|
49
|
-
json: opts.json || quiet,
|
|
50
|
-
benchmark: quiet ? false : opts.benchmark !== false,
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
if (opts.track) {
|
|
54
|
-
const providers = getAvailableProviders();
|
|
55
|
-
if (providers.length === 0) {
|
|
56
|
-
console.log("\n --track needs API keys. Set one or more:");
|
|
57
|
-
console.log(" ANTHROPIC_API_KEY (Claude)");
|
|
58
|
-
console.log(" OPENAI_API_KEY (ChatGPT)");
|
|
59
|
-
console.log(" GOOGLE_API_KEY (Gemini)\n");
|
|
60
|
-
} else {
|
|
61
|
-
const trackResults = await track(result, {
|
|
62
|
-
company: opts.company || null,
|
|
63
|
-
category: opts.category || null,
|
|
64
|
-
});
|
|
65
|
-
printTrackResults(trackResults, opts.company);
|
|
66
|
-
}
|
|
38
|
+
if (url && !url.startsWith("http")) url = `https://${url}`;
|
|
39
|
+
if (!url) {
|
|
40
|
+
console.error(" Usage: npx aeo-ready scan <url>");
|
|
41
|
+
console.error(" npx aeo-ready scan <url> --dir ./public");
|
|
42
|
+
process.exit(1);
|
|
67
43
|
}
|
|
68
44
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
console.log(
|
|
75
|
-
"\n --fix requires repo mode (no --url). Files are written to current directory.\n",
|
|
76
|
-
);
|
|
77
|
-
}
|
|
45
|
+
const result = await scan({
|
|
46
|
+
url,
|
|
47
|
+
dir: opts.dir || null,
|
|
48
|
+
json: opts.json || false,
|
|
49
|
+
});
|
|
78
50
|
|
|
79
51
|
if (opts.json) {
|
|
80
52
|
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
81
53
|
}
|
|
82
54
|
|
|
83
|
-
if (opts.threshold && result.
|
|
55
|
+
if (opts.threshold && result.averageScore < opts.threshold) {
|
|
84
56
|
process.exit(1);
|
|
85
57
|
}
|
|
86
58
|
} catch (err) {
|
|
@@ -89,18 +61,4 @@ program
|
|
|
89
61
|
}
|
|
90
62
|
});
|
|
91
63
|
|
|
92
|
-
program
|
|
93
|
-
.command("init")
|
|
94
|
-
.description("Create .agent-web/config.yaml for citation tracking")
|
|
95
|
-
.action(() => {
|
|
96
|
-
console.log("Coming in v2.0 — citation tracking config");
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
program
|
|
100
|
-
.command("history")
|
|
101
|
-
.description("List past scan results")
|
|
102
|
-
.action(() => {
|
|
103
|
-
console.log("Coming in v1.2 — scan history");
|
|
104
|
-
});
|
|
105
|
-
|
|
106
64
|
program.parse();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aeo-ready",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "AEO benchmark aggregator. One scan, every score. Collects agentic-seo, Cloudflare, and Fern in one report.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"aeo-ready": "./bin/cli.js"
|
|
@@ -1,14 +1,109 @@
|
|
|
1
1
|
import { execSync } from "child_process";
|
|
2
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join, dirname } from "path";
|
|
5
|
+
|
|
6
|
+
const KNOWN_FILES = [
|
|
7
|
+
"robots.txt",
|
|
8
|
+
"llms.txt",
|
|
9
|
+
"llms-full.txt",
|
|
10
|
+
"AGENTS.md",
|
|
11
|
+
"CLAUDE.md",
|
|
12
|
+
"skill.md",
|
|
13
|
+
"agent-permissions.json",
|
|
14
|
+
"agents.json",
|
|
15
|
+
"sitemap.xml",
|
|
16
|
+
".well-known/ai-plugin.json",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
async function fetchText(url) {
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
22
|
+
if (!res.ok) return null;
|
|
23
|
+
return await res.text();
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseSitemapUrls(xml, baseUrl) {
|
|
30
|
+
const urls = [];
|
|
31
|
+
const matches = xml.matchAll(/<loc>([^<]+)<\/loc>/g);
|
|
32
|
+
for (const m of matches) {
|
|
33
|
+
const loc = m[1].trim();
|
|
34
|
+
if (loc.startsWith(baseUrl)) urls.push(loc);
|
|
35
|
+
}
|
|
36
|
+
return urls;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function urlToFilePath(url, baseUrl) {
|
|
40
|
+
let path = new URL(url).pathname;
|
|
41
|
+
if (path.endsWith("/")) path += "index.html";
|
|
42
|
+
else if (!path.includes(".")) path += ".html";
|
|
43
|
+
return path.replace(/^\//, "");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function fetchSiteToDir(baseUrl) {
|
|
47
|
+
const tempDir = mkdtempSync(join(tmpdir(), "aeo-"));
|
|
48
|
+
|
|
49
|
+
for (const file of KNOWN_FILES) {
|
|
50
|
+
const content = await fetchText(`${baseUrl}/${file}`);
|
|
51
|
+
if (content) {
|
|
52
|
+
const filePath = join(tempDir, file);
|
|
53
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
54
|
+
writeFileSync(filePath, content);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const sitemap = await fetchText(`${baseUrl}/sitemap.xml`);
|
|
59
|
+
if (!sitemap) return tempDir;
|
|
60
|
+
|
|
61
|
+
const urls = parseSitemapUrls(sitemap, baseUrl);
|
|
62
|
+
|
|
63
|
+
await Promise.all(
|
|
64
|
+
urls.map(async (url) => {
|
|
65
|
+
const path = urlToFilePath(url, baseUrl);
|
|
66
|
+
if (KNOWN_FILES.includes(path)) return;
|
|
67
|
+
|
|
68
|
+
const html = await fetchText(url);
|
|
69
|
+
if (html) {
|
|
70
|
+
const htmlPath = join(tempDir, path);
|
|
71
|
+
mkdirSync(dirname(htmlPath), { recursive: true });
|
|
72
|
+
writeFileSync(htmlPath, html);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const mdUrl = url
|
|
76
|
+
.replace(/\.html$/, ".md")
|
|
77
|
+
.replace(/\/?$/, (m) => (m === "/" ? "/index.md" : ".md"));
|
|
78
|
+
if (mdUrl !== url) {
|
|
79
|
+
const md = await fetchText(mdUrl);
|
|
80
|
+
if (md) {
|
|
81
|
+
const mdPath = join(tempDir, path.replace(/\.html$/, ".md"));
|
|
82
|
+
mkdirSync(dirname(mdPath), { recursive: true });
|
|
83
|
+
writeFileSync(mdPath, md);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return tempDir;
|
|
90
|
+
}
|
|
2
91
|
|
|
3
92
|
export async function runBenchmark(target) {
|
|
93
|
+
let tempDir = null;
|
|
94
|
+
|
|
4
95
|
try {
|
|
5
|
-
|
|
6
|
-
target && target.startsWith("http")
|
|
7
|
-
? `--url ${target} --json`
|
|
8
|
-
: `${target || "."} --json`;
|
|
96
|
+
let scanDir;
|
|
9
97
|
|
|
10
|
-
|
|
11
|
-
|
|
98
|
+
if (target && target.startsWith("http")) {
|
|
99
|
+
tempDir = await fetchSiteToDir(target);
|
|
100
|
+
scanDir = tempDir;
|
|
101
|
+
} else {
|
|
102
|
+
scanDir = target || ".";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const output = execSync(`npx agentic-seo ${scanDir} --json`, {
|
|
106
|
+
timeout: 60000,
|
|
12
107
|
encoding: "utf8",
|
|
13
108
|
stdio: ["pipe", "pipe", "pipe"],
|
|
14
109
|
});
|
|
@@ -36,5 +131,11 @@ export async function runBenchmark(target) {
|
|
|
36
131
|
available: false,
|
|
37
132
|
reason: err.message?.slice(0, 100),
|
|
38
133
|
};
|
|
134
|
+
} finally {
|
|
135
|
+
if (tempDir) {
|
|
136
|
+
try {
|
|
137
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
138
|
+
} catch {}
|
|
139
|
+
}
|
|
39
140
|
}
|
|
40
141
|
}
|
package/src/benchmark/index.js
CHANGED
|
@@ -27,11 +27,11 @@ const REFERENCE_SCORES = {
|
|
|
27
27
|
},
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
-
export async function runAllBenchmarks(target) {
|
|
30
|
+
export async function runAllBenchmarks(target, dir) {
|
|
31
31
|
const isUrl = target && target.startsWith("http");
|
|
32
32
|
|
|
33
33
|
const results = await Promise.allSettled([
|
|
34
|
-
runAgenticSeo(target),
|
|
34
|
+
runAgenticSeo(dir || target),
|
|
35
35
|
isUrl ? runCloudflare(target) : Promise.resolve(null),
|
|
36
36
|
isUrl ? runFern(target) : Promise.resolve(null),
|
|
37
37
|
]);
|
|
@@ -2,11 +2,9 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { getHistory } from "../history/index.js";
|
|
4
4
|
import { renderOverallScore } from "./sections/overall-score.js";
|
|
5
|
-
import { renderAgentReadiness } from "./sections/agent-readiness.js";
|
|
6
|
-
import { renderAiVisibility } from "./sections/ai-visibility.js";
|
|
7
5
|
import { renderHistoryTable } from "./sections/history-table.js";
|
|
8
6
|
import { renderTrendChart } from "./sections/trend-chart.js";
|
|
9
|
-
import {
|
|
7
|
+
import { renderBenchmarkDetails } from "./sections/benchmark-details.js";
|
|
10
8
|
|
|
11
9
|
const DASHBOARD_DIR = ".aeo-ready";
|
|
12
10
|
const DASHBOARD_FILE = "dashboard.html";
|
|
@@ -19,12 +17,10 @@ export async function generateDashboard(scanResult, dir, opts = {}) {
|
|
|
19
17
|
const history = getHistory(dir);
|
|
20
18
|
|
|
21
19
|
const sections = {
|
|
22
|
-
"overall-score": renderOverallScore(scanResult
|
|
23
|
-
"
|
|
24
|
-
"ai-visibility-scorecard": renderAiVisibility(scanResult),
|
|
25
|
-
"history-table": renderHistoryTable(history),
|
|
20
|
+
"overall-score": renderOverallScore(scanResult),
|
|
21
|
+
"benchmark-details": renderBenchmarkDetails(scanResult),
|
|
26
22
|
"trend-chart": renderTrendChart(history),
|
|
27
|
-
|
|
23
|
+
"history-table": renderHistoryTable(history),
|
|
28
24
|
};
|
|
29
25
|
|
|
30
26
|
let html;
|
|
@@ -54,13 +50,12 @@ function replaceSection(html, name, content) {
|
|
|
54
50
|
|
|
55
51
|
function buildFullDashboard(sections, scanResult) {
|
|
56
52
|
const timestamp = new Date().toISOString().slice(0, 10);
|
|
57
|
-
const target = scanResult.target || "Local";
|
|
58
53
|
|
|
59
54
|
return `<!DOCTYPE html>
|
|
60
55
|
<html lang="en">
|
|
61
56
|
<head>
|
|
62
57
|
<meta charset="UTF-8">
|
|
63
|
-
<title>aeo-ready —
|
|
58
|
+
<title>aeo-ready — AEO Benchmark Dashboard</title>
|
|
64
59
|
<style>
|
|
65
60
|
${CSS}
|
|
66
61
|
</style>
|
|
@@ -69,30 +64,24 @@ ${CSS}
|
|
|
69
64
|
|
|
70
65
|
<nav>
|
|
71
66
|
<h2>aeo-ready</h2>
|
|
72
|
-
<a href="#overall" class="section-head">
|
|
73
|
-
<a href="#
|
|
74
|
-
<a href="#ai-visibility" class="section-head">AI Visibility</a>
|
|
67
|
+
<a href="#overall" class="section-head">Scores</a>
|
|
68
|
+
<a href="#details" class="section-head">Benchmark Details</a>
|
|
75
69
|
<a href="#trends" class="section-head">Trends</a>
|
|
76
70
|
<a href="#history" class="section-head">History</a>
|
|
77
|
-
<a href="#recommendations" class="section-head">Recommendations</a>
|
|
78
71
|
</nav>
|
|
79
72
|
|
|
80
73
|
<main>
|
|
81
74
|
|
|
82
|
-
<h1>
|
|
83
|
-
<p class="subtitle">${
|
|
75
|
+
<h1>AEO Benchmark Dashboard</h1>
|
|
76
|
+
<p class="subtitle">${scanResult.url} · ${timestamp}</p>
|
|
84
77
|
|
|
85
78
|
<!-- SECTION:overall-score -->
|
|
86
79
|
${sections["overall-score"]}
|
|
87
80
|
<!-- /SECTION:overall-score -->
|
|
88
81
|
|
|
89
|
-
<!-- SECTION:
|
|
90
|
-
${sections["
|
|
91
|
-
<!-- /SECTION:
|
|
92
|
-
|
|
93
|
-
<!-- SECTION:ai-visibility-scorecard -->
|
|
94
|
-
${sections["ai-visibility-scorecard"]}
|
|
95
|
-
<!-- /SECTION:ai-visibility-scorecard -->
|
|
82
|
+
<!-- SECTION:benchmark-details -->
|
|
83
|
+
${sections["benchmark-details"]}
|
|
84
|
+
<!-- /SECTION:benchmark-details -->
|
|
96
85
|
|
|
97
86
|
<!-- SECTION:trend-chart -->
|
|
98
87
|
${sections["trend-chart"]}
|
|
@@ -102,10 +91,6 @@ ${sections["trend-chart"]}
|
|
|
102
91
|
${sections["history-table"]}
|
|
103
92
|
<!-- /SECTION:history-table -->
|
|
104
93
|
|
|
105
|
-
<!-- SECTION:recommendations -->
|
|
106
|
-
${sections["recommendations"]}
|
|
107
|
-
<!-- /SECTION:recommendations -->
|
|
108
|
-
|
|
109
94
|
</main>
|
|
110
95
|
</body>
|
|
111
96
|
</html>`;
|
|
@@ -123,34 +108,23 @@ h1 { color: #f0f6fc; font-size: 24px; margin-bottom: 4px; }
|
|
|
123
108
|
.subtitle { color: #8b949e; font-size: 13px; margin-bottom: 32px; }
|
|
124
109
|
h2 { color: #f0f6fc; font-size: 18px; margin-top: 40px; margin-bottom: 16px; padding-top: 16px; border-top: 1px solid #21262d; }
|
|
125
110
|
h3 { color: #f0f6fc; font-size: 14px; margin-top: 16px; margin-bottom: 8px; }
|
|
126
|
-
.
|
|
127
|
-
.score-
|
|
128
|
-
.score-
|
|
129
|
-
.score-
|
|
130
|
-
.score-
|
|
111
|
+
.scores { display: flex; gap: 16px; flex-wrap: wrap; margin: 16px 0; }
|
|
112
|
+
.score-card { background: #161b22; border: 1px solid #21262d; border-radius: 8px; padding: 20px; flex: 1; min-width: 180px; text-align: center; }
|
|
113
|
+
.score-card .name { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #8b949e; margin-bottom: 8px; }
|
|
114
|
+
.score-card .grade { font-size: 36px; font-weight: 700; }
|
|
115
|
+
.score-card .number { font-size: 14px; color: #8b949e; margin-top: 4px; }
|
|
116
|
+
.score-card .bar { margin: 8px auto; width: 100%; height: 4px; background: #21262d; border-radius: 2px; overflow: hidden; }
|
|
117
|
+
.score-card .bar-fill { height: 100%; border-radius: 2px; }
|
|
131
118
|
.grade-a { color: #3fb950; } .grade-b { color: #79c0ff; } .grade-c { color: #d29922; } .grade-d { color: #f85149; } .grade-f { color: #f85149; }
|
|
132
119
|
.bar-a { background: #3fb950; } .bar-b { background: #79c0ff; } .bar-c { background: #d29922; } .bar-d { background: #f85149; } .bar-f { background: #f85149; }
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
.scorecard-header h3 { margin: 0; }
|
|
136
|
-
.scorecard-header .pct { font-size: 20px; font-weight: 600; }
|
|
137
|
-
.category { margin: 12px 0; padding: 8px 0; border-bottom: 1px solid #21262d; }
|
|
138
|
-
.category:last-child { border-bottom: none; }
|
|
139
|
-
.cat-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
|
140
|
-
.cat-name { font-size: 13px; font-weight: 500; flex: 1; }
|
|
141
|
-
.cat-score { font-size: 12px; color: #8b949e; }
|
|
142
|
-
.cat-bar { width: 120px; height: 4px; background: #21262d; border-radius: 2px; overflow: hidden; }
|
|
143
|
-
.cat-bar-fill { height: 100%; border-radius: 2px; }
|
|
120
|
+
details { background: #161b22; border: 1px solid #21262d; border-radius: 8px; padding: 16px; margin: 12px 0; cursor: pointer; }
|
|
121
|
+
details summary { font-size: 14px; font-weight: 500; color: #f0f6fc; display: flex; justify-content: space-between; }
|
|
144
122
|
.check { font-size: 12px; padding: 2px 0 2px 16px; color: #8b949e; }
|
|
145
123
|
.check.pass { color: #3fb950; }
|
|
146
124
|
.check.fail { color: #f85149; }
|
|
147
|
-
.
|
|
125
|
+
.compare { font-size: 11px; color: #8b949e; margin-top: 8px; padding-top: 8px; border-top: 1px solid #21262d; }
|
|
148
126
|
table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
|
149
127
|
th, td { text-align: left; padding: 8px 12px; font-size: 12px; border-bottom: 1px solid #21262d; }
|
|
150
128
|
th { color: #8b949e; font-weight: 600; }
|
|
151
129
|
.trend { color: #3fb950; } .regression { color: #f85149; }
|
|
152
|
-
.rec { background: #161b22; border: 1px solid #21262d; border-radius: 6px; padding: 12px 16px; margin: 8px 0; }
|
|
153
|
-
.rec-title { font-size: 13px; font-weight: 500; color: #f0f6fc; }
|
|
154
|
-
.rec-fix { font-size: 12px; color: #8b949e; margin-top: 4px; }
|
|
155
|
-
.rec-impact { font-size: 11px; color: #d29922; margin-top: 4px; }
|
|
156
130
|
svg { display: block; margin: 16px 0; }`;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const REFERENCE_SCORES = {
|
|
2
|
+
agenticSeo: {
|
|
3
|
+
Cloudflare: 55,
|
|
4
|
+
Supabase: 52,
|
|
5
|
+
Vercel: 48,
|
|
6
|
+
Average: 25,
|
|
7
|
+
Stripe: 17,
|
|
8
|
+
},
|
|
9
|
+
cloudflare: { Cloudflare: 5, Vercel: 4, Supabase: 3, Stripe: 2, Average: 2 },
|
|
10
|
+
fern: { Stripe: 85, Supabase: 78, Anthropic: 72, Vercel: 60, Average: 45 },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function renderBenchmarkDetails(result) {
|
|
14
|
+
const { benchmarks } = result;
|
|
15
|
+
|
|
16
|
+
let html = `<h2 id="details">Benchmark Details</h2>
|
|
17
|
+
<p style="color:#8b949e;font-size:12px;margin-bottom:12px;">Expand each source to see per-check results and how other companies score.</p>`;
|
|
18
|
+
|
|
19
|
+
if (benchmarks.agenticSeo?.available) {
|
|
20
|
+
html += renderSource("agentic-seo", "agenticSeo", benchmarks.agenticSeo);
|
|
21
|
+
}
|
|
22
|
+
if (benchmarks.cloudflare?.available) {
|
|
23
|
+
html += renderSource("Cloudflare", "cloudflare", benchmarks.cloudflare);
|
|
24
|
+
}
|
|
25
|
+
if (benchmarks.fern?.available) {
|
|
26
|
+
html += renderSource("Fern", "fern", benchmarks.fern);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return html;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function renderSource(name, key, data) {
|
|
33
|
+
const pct =
|
|
34
|
+
data.maxScore > 0 ? Math.round((data.score / data.maxScore) * 100) : 0;
|
|
35
|
+
|
|
36
|
+
let html = `\n<details>
|
|
37
|
+
<summary>
|
|
38
|
+
<span>${esc(name)}</span>
|
|
39
|
+
<span style="color:#8b949e;">${data.score}/${data.maxScore}${data.grade ? ` (${data.grade})` : ""}</span>
|
|
40
|
+
</summary>
|
|
41
|
+
<div style="margin-top:12px;">`;
|
|
42
|
+
|
|
43
|
+
if (data.checks && data.checks.length > 0) {
|
|
44
|
+
for (const check of data.checks) {
|
|
45
|
+
if (check.status === "pass") {
|
|
46
|
+
html += `\n <div class="check pass">+ ${esc(check.id)} ${check.message ? `<span style="color:#8b949e;font-size:11px;">${esc(check.message.slice(0, 80))}</span>` : ""}</div>`;
|
|
47
|
+
} else if (check.status === "fail") {
|
|
48
|
+
html += `\n <div class="check fail">- ${esc(check.id)} ${check.message ? `<span style="color:#8b949e;font-size:11px;">${esc(check.message.slice(0, 80))}</span>` : ""}</div>`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} else if (data.categories) {
|
|
52
|
+
for (const [, cat] of Object.entries(data.categories)) {
|
|
53
|
+
const catPct =
|
|
54
|
+
cat.maxScore > 0 ? Math.round((cat.score / cat.maxScore) * 100) : 0;
|
|
55
|
+
const icon = catPct >= 80 ? "+" : catPct >= 40 ? "~" : "-";
|
|
56
|
+
const cls = catPct >= 80 ? "pass" : "fail";
|
|
57
|
+
html += `\n <div class="check ${cls}">${icon} ${esc(cat.name || "")} ${cat.score}/${cat.maxScore}</div>`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const refs = REFERENCE_SCORES[key];
|
|
62
|
+
if (refs) {
|
|
63
|
+
const lines = Object.entries(refs)
|
|
64
|
+
.sort((a, b) => b[1] - a[1])
|
|
65
|
+
.map(([n, s]) => `${n}: ${s}`)
|
|
66
|
+
.join(" · ");
|
|
67
|
+
html += `\n <div class="compare">Others: ${lines}</div>`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
html += `\n </div>\n</details>`;
|
|
71
|
+
return html;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function esc(s) {
|
|
75
|
+
return (s || "")
|
|
76
|
+
.replace(/&/g, "&")
|
|
77
|
+
.replace(/</g, "<")
|
|
78
|
+
.replace(/>/g, ">");
|
|
79
|
+
}
|