aeo-ready 1.2.0 → 1.3.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/CHANGELOG.md +43 -0
- package/README.md +76 -73
- package/bin/cli.js +39 -2
- package/package.json +20 -1
- package/src/benchmark/agentic-seo.js +107 -6
- package/src/benchmark/index.js +40 -30
- package/src/history/index.js +5 -1
- package/src/scan.js +91 -48
- package/src/utils/fetch.js +1 -1
- package/.claude/settings.local.json +0 -7
- package/src/dashboard/generate.js +0 -130
- package/src/dashboard/sections/benchmark-details.js +0 -79
- package/src/dashboard/sections/history-table.js +0 -39
- package/src/dashboard/sections/overall-score.js +0 -65
- package/src/dashboard/sections/trend-chart.js +0 -63
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.3.1
|
|
4
|
+
|
|
5
|
+
- Add progress indicator during scan
|
|
6
|
+
- Add `history` command to view past scans
|
|
7
|
+
- Fix silent catch on corrupt history file — now warns instead of silently returning empty
|
|
8
|
+
- Fix error detail missing from `agentic-seo init` and `afdocs` failures
|
|
9
|
+
- Fix User-Agent from old "agent-web/1.0" to "aeo-ready/1.3"
|
|
10
|
+
- Add programmatic API docs to README
|
|
11
|
+
- Add `files` field to package.json — excludes dead dashboard code from npm (~12KB smaller)
|
|
12
|
+
- Add `repository`, `homepage`, `bugs` fields to package.json
|
|
13
|
+
- Add issue templates (bug report, feature request)
|
|
14
|
+
- Add CHANGELOG.md
|
|
15
|
+
- Link best-practices.md from README
|
|
16
|
+
|
|
17
|
+
## 1.3.0
|
|
18
|
+
|
|
19
|
+
- Terminal-only output, remove dashboard auto-open
|
|
20
|
+
- Interactive "Fix now?" prompt after scan
|
|
21
|
+
- Fetch site pages for agentic-seo instead of URL-only mode
|
|
22
|
+
- Add .aeo-ready/ to gitignore
|
|
23
|
+
|
|
24
|
+
## 1.2.0
|
|
25
|
+
|
|
26
|
+
- Rewrite as pure aggregator — removed proprietary scoring
|
|
27
|
+
- Interactive "Fix now?" prompt after scan runs `agentic-seo init` + `afdocs`
|
|
28
|
+
- Terminal-only output, removed dashboard auto-open
|
|
29
|
+
|
|
30
|
+
## 1.1.0
|
|
31
|
+
|
|
32
|
+
- Aggregator benchmarks: agentic-seo + Cloudflare + Fern in one scan
|
|
33
|
+
- Per-check detail with pass/fail and company comparisons
|
|
34
|
+
- `--dir` flag for local agentic-seo scanning (92 vs 23 in URL-only mode)
|
|
35
|
+
- `--json` and `--threshold` flags for CI
|
|
36
|
+
- Score history in `.aeo-ready/history.json`
|
|
37
|
+
- Dashboard generation (HTML)
|
|
38
|
+
|
|
39
|
+
## 1.0.0
|
|
40
|
+
|
|
41
|
+
- Initial release
|
|
42
|
+
- Unified AEO CLI with two scorecards, fix mode, dashboard
|
|
43
|
+
- `agent-web` skill for Claude Code
|
package/README.md
CHANGED
|
@@ -1,115 +1,118 @@
|
|
|
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/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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
42
|
+
aeo-ready — yoursite.com
|
|
43
|
+
|
|
44
|
+
agentic-seo ·································· 91/100 A
|
|
45
|
+
✓ Discovery 25/25
|
|
46
|
+
◑ Content Structure 18/25
|
|
47
|
+
✓ Token Economics 25/25
|
|
48
|
+
✓ Capability Signaling 15/15
|
|
49
|
+
✓ UX Bridge 8/10
|
|
50
|
+
vs Cloudflare 55 · Supabase 52 · Vercel 48 · Stripe 17
|
|
51
|
+
|
|
52
|
+
Cloudflare ···································· 4/5 B
|
|
53
|
+
10 passed 2 failed
|
|
54
|
+
✗ robotsTxtAiRules No rules for AI bots found
|
|
55
|
+
✗ contentSignals No content signals in robots.txt
|
|
56
|
+
vs Cloudflare 5 · Vercel 4 · Supabase 3 · Stripe 2
|
|
57
|
+
|
|
58
|
+
Fern ········································ 83/100 B
|
|
59
|
+
9 passed 4 failed
|
|
60
|
+
✗ llms-txt-links-markdown Links point to HTML, no markdown
|
|
61
|
+
✗ content-start-position 2 pages have content past 50%
|
|
62
|
+
✗ llms-txt-coverage Covers 67% of sitemap
|
|
63
|
+
✗ markdown-content-parity 4 pages have content differences
|
|
64
|
+
vs Stripe 85 · Supabase 78 · Anthropic 72 · Vercel 60
|
|
65
|
+
|
|
66
|
+
──────────────────────────────────────────────────
|
|
67
|
+
Overall 85/100
|
|
68
|
+
|
|
69
|
+
Next steps
|
|
70
|
+
npx agentic-seo init scaffold llms.txt, AGENTS.md
|
|
71
|
+
npx afdocs https://yoursite.com 4 Fern issues
|
|
72
|
+
npx skills add katrinalaszlo/agent-serve make your product agent-ready
|
|
73
|
+
|
|
74
|
+
Fix now? [y/N]
|
|
66
75
|
```
|
|
67
76
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
Exit with code 1 if below threshold:
|
|
71
|
-
|
|
72
|
-
```bash
|
|
73
|
-
npx agent-web scan --json --threshold 60
|
|
74
|
-
```
|
|
77
|
+
Say `y` and it runs `npx agentic-seo init` to scaffold missing files (llms.txt, AGENTS.md, skill.md), then `npx afdocs` for Fern issues. Non-interactive in CI (`--json` or non-TTY).
|
|
75
78
|
|
|
76
|
-
|
|
79
|
+
## CI Mode
|
|
77
80
|
|
|
78
81
|
```yaml
|
|
79
|
-
- run: npx
|
|
82
|
+
- run: npx aeo-ready scan yoursite.com --dir ./public --threshold 50
|
|
80
83
|
```
|
|
81
84
|
|
|
82
|
-
## Benchmark
|
|
83
|
-
|
|
84
|
-
Runs `npx agentic-seo --json` as an independent second opinion. Shows their score alongside yours. If agentic-seo isn't installed, the benchmark line is skipped gracefully.
|
|
85
|
-
|
|
86
85
|
## History
|
|
87
86
|
|
|
88
|
-
|
|
87
|
+
Scores persist in `.aeo-ready/history.json`. Re-scan to track improvement over time.
|
|
89
88
|
|
|
90
|
-
|
|
89
|
+
```bash
|
|
90
|
+
npx aeo-ready history # show last 10 scans
|
|
91
|
+
```
|
|
91
92
|
|
|
92
|
-
|
|
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
|
|
93
|
+
## Programmatic API
|
|
97
94
|
|
|
98
|
-
|
|
95
|
+
```js
|
|
96
|
+
import { scan, getHistory } from "aeo-ready";
|
|
99
97
|
|
|
100
|
-
|
|
98
|
+
const result = await scan({ url: "https://yoursite.com", dir: "./public", json: true });
|
|
99
|
+
// result.averageScore, result.benchmarks.agenticSeo, .cloudflare, .fern
|
|
101
100
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
const history = getHistory(process.cwd());
|
|
102
|
+
// history.scans — array of past scan results
|
|
103
|
+
```
|
|
105
104
|
|
|
106
|
-
##
|
|
105
|
+
## Next step: make your product agent-ready
|
|
106
|
+
|
|
107
|
+
`aeo-ready` tells you how discoverable your site is to AI agents. To actually serve those agents — structured content, tool definitions, skill endpoints — use [agent-serve](https://github.com/katrinalaszlo/agent-serve):
|
|
107
108
|
|
|
108
109
|
```bash
|
|
109
|
-
npx skills add katrinalaszlo/agent-
|
|
110
|
+
npx skills add katrinalaszlo/agent-serve
|
|
110
111
|
```
|
|
111
112
|
|
|
112
|
-
|
|
113
|
+
## Best practices by site type
|
|
114
|
+
|
|
115
|
+
See [skills/agent-web/best-practices.md](skills/agent-web/best-practices.md) for an opinionated AEO framework covering SaaS, personal/portfolio, API/developer tools, and content/blog sites.
|
|
113
116
|
|
|
114
117
|
## Author
|
|
115
118
|
|
package/bin/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
import { scan } from "../src/scan.js";
|
|
5
|
+
import { getHistory } from "../src/history/index.js";
|
|
5
6
|
import { readFileSync } from "fs";
|
|
6
7
|
import { dirname, join } from "path";
|
|
7
8
|
import { fileURLToPath } from "url";
|
|
@@ -20,7 +21,13 @@ program
|
|
|
20
21
|
|
|
21
22
|
program
|
|
22
23
|
.command("scan [url]")
|
|
23
|
-
.description(
|
|
24
|
+
.description(
|
|
25
|
+
"Run all AEO benchmarks against a URL (add --dir for local scanning)",
|
|
26
|
+
)
|
|
27
|
+
.option(
|
|
28
|
+
"-d, --dir <path>",
|
|
29
|
+
"Local directory to scan (gives agentic-seo full access)",
|
|
30
|
+
)
|
|
24
31
|
.option("--json", "Output results as JSON")
|
|
25
32
|
.option(
|
|
26
33
|
"--threshold <number>",
|
|
@@ -32,10 +39,15 @@ program
|
|
|
32
39
|
if (url && !url.startsWith("http")) url = `https://${url}`;
|
|
33
40
|
if (!url) {
|
|
34
41
|
console.error(" Usage: npx aeo-ready scan <url>");
|
|
42
|
+
console.error(" npx aeo-ready scan <url> --dir ./public");
|
|
35
43
|
process.exit(1);
|
|
36
44
|
}
|
|
37
45
|
|
|
38
|
-
const result = await scan({
|
|
46
|
+
const result = await scan({
|
|
47
|
+
url,
|
|
48
|
+
dir: opts.dir || null,
|
|
49
|
+
json: opts.json || false,
|
|
50
|
+
});
|
|
39
51
|
|
|
40
52
|
if (opts.json) {
|
|
41
53
|
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
@@ -50,4 +62,29 @@ program
|
|
|
50
62
|
}
|
|
51
63
|
});
|
|
52
64
|
|
|
65
|
+
program
|
|
66
|
+
.command("history")
|
|
67
|
+
.description("Show past scan scores")
|
|
68
|
+
.action(() => {
|
|
69
|
+
const history = getHistory(process.cwd());
|
|
70
|
+
if (history.scans.length === 0) {
|
|
71
|
+
console.log(" No scans yet. Run: npx aeo-ready scan <url>");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
console.log("");
|
|
75
|
+
const rows = history.scans.slice(-10).map((s) => {
|
|
76
|
+
const date = new Date(s.timestamp).toLocaleDateString();
|
|
77
|
+
const parts = [];
|
|
78
|
+
if (s.agenticSeo != null) parts.push(`seo:${s.agenticSeo}`);
|
|
79
|
+
if (s.cloudflare != null)
|
|
80
|
+
parts.push(`cf:${s.cloudflare}/${s.cloudflareMax || "?"}`);
|
|
81
|
+
if (s.fern != null) parts.push(`fern:${s.fern}`);
|
|
82
|
+
return ` ${date.padEnd(12)} ${String(s.averageScore).padEnd(6)} ${parts.join(" ")} ${s.url}`;
|
|
83
|
+
});
|
|
84
|
+
console.log(` ${"Date".padEnd(12)} ${"Avg".padEnd(6)} Scores`);
|
|
85
|
+
console.log(` ${"─".repeat(60)}`);
|
|
86
|
+
for (const row of rows) console.log(row);
|
|
87
|
+
console.log("");
|
|
88
|
+
});
|
|
89
|
+
|
|
53
90
|
program.parse();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aeo-ready",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
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": {
|
|
@@ -25,6 +25,25 @@
|
|
|
25
25
|
],
|
|
26
26
|
"author": "Kat Laszlo <kat@tansohq.com>",
|
|
27
27
|
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/katrinalaszlo/aeo-ready.git"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/katrinalaszlo/aeo-ready#readme",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/katrinalaszlo/aeo-ready/issues"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"bin/",
|
|
38
|
+
"src/benchmark/",
|
|
39
|
+
"src/history/",
|
|
40
|
+
"src/utils/",
|
|
41
|
+
"src/index.js",
|
|
42
|
+
"src/scan.js",
|
|
43
|
+
"skills/",
|
|
44
|
+
"README.md",
|
|
45
|
+
"CHANGELOG.md"
|
|
46
|
+
],
|
|
28
47
|
"engines": {
|
|
29
48
|
"node": ">=18.0.0"
|
|
30
49
|
},
|
|
@@ -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,13 @@ const REFERENCE_SCORES = {
|
|
|
27
27
|
},
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
const W = 52;
|
|
31
|
+
|
|
32
|
+
export async function runAllBenchmarks(target, dir) {
|
|
31
33
|
const isUrl = target && target.startsWith("http");
|
|
32
34
|
|
|
33
35
|
const results = await Promise.allSettled([
|
|
34
|
-
runAgenticSeo(target),
|
|
36
|
+
runAgenticSeo(dir || target),
|
|
35
37
|
isUrl ? runCloudflare(target) : Promise.resolve(null),
|
|
36
38
|
isUrl ? runFern(target) : Promise.resolve(null),
|
|
37
39
|
]);
|
|
@@ -47,9 +49,7 @@ export function printBenchmarks(benchmarks) {
|
|
|
47
49
|
const any = Object.values(benchmarks).some((b) => b && b.available);
|
|
48
50
|
if (!any) return;
|
|
49
51
|
|
|
50
|
-
console.log(
|
|
51
|
-
chalk.bold("\n ─── Benchmarks ────────────────────────────────────\n"),
|
|
52
|
-
);
|
|
52
|
+
console.log("");
|
|
53
53
|
|
|
54
54
|
if (benchmarks.agenticSeo?.available) {
|
|
55
55
|
printBenchmarkBlock("agentic-seo", "agenticSeo", benchmarks.agenticSeo);
|
|
@@ -62,30 +62,42 @@ export function printBenchmarks(benchmarks) {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
function scoreColor(pct) {
|
|
66
|
+
return pct >= 80 ? chalk.green : pct >= 50 ? chalk.yellow : chalk.red;
|
|
67
|
+
}
|
|
68
|
+
|
|
65
69
|
function printBenchmarkBlock(name, key, b) {
|
|
66
70
|
const pct = b.maxScore > 0 ? Math.round((b.score / b.maxScore) * 100) : 0;
|
|
67
|
-
const color = pct
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
+
const color = scoreColor(pct);
|
|
72
|
+
const scoreStr = `${b.score}/${b.maxScore}`;
|
|
73
|
+
const gradeStr = b.grade ? ` ${b.grade}` : "";
|
|
74
|
+
const right = `${scoreStr}${gradeStr}`;
|
|
75
|
+
const dots = W - name.length - right.length;
|
|
76
|
+
const leader = dots > 2 ? " " + chalk.dim("·".repeat(dots - 2)) + " " : " ";
|
|
71
77
|
|
|
72
|
-
console.log(
|
|
73
|
-
` ${bar} ${chalk.bold(name.padEnd(16))} ${chalk.dim(`${b.score}/${b.maxScore}`)}${b.grade ? chalk.dim(` (${b.grade})`) : ""}`,
|
|
74
|
-
);
|
|
78
|
+
console.log(` ${chalk.bold(name)}${leader}${color(right)}`);
|
|
75
79
|
|
|
76
80
|
if (b.checks && b.checks.length > 0) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
chalk.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
81
|
+
const passed = b.checks.filter((c) => c.status === "pass");
|
|
82
|
+
const failed = b.checks.filter(
|
|
83
|
+
(c) => c.status === "fail" || c.status === "warn",
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
if (passed.length > 0 && failed.length > 0) {
|
|
87
|
+
console.log(
|
|
88
|
+
chalk.dim(
|
|
89
|
+
` ${chalk.green(passed.length + " passed")} ${chalk.red(failed.length + " failed")}`,
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
} else if (passed.length > 0) {
|
|
93
|
+
console.log(chalk.dim(` ${chalk.green(passed.length + " passed")}`));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const check of failed) {
|
|
97
|
+
const msg = check.message
|
|
98
|
+
? chalk.dim(` ${check.message.slice(0, 50)}`)
|
|
99
|
+
: "";
|
|
100
|
+
console.log(` ${chalk.red("✗")} ${check.id}${msg}`);
|
|
89
101
|
}
|
|
90
102
|
} else if (b.categories) {
|
|
91
103
|
for (const [, cat] of Object.entries(b.categories)) {
|
|
@@ -99,20 +111,18 @@ function printBenchmarkBlock(name, key, b) {
|
|
|
99
111
|
? chalk.yellow("◑")
|
|
100
112
|
: chalk.red("✗");
|
|
101
113
|
console.log(
|
|
102
|
-
chalk.dim(
|
|
103
|
-
` ${icon} ${(cat.name || "").padEnd(22)} ${cat.score}/${cat.maxScore}`,
|
|
104
|
-
),
|
|
114
|
+
` ${icon} ${chalk.dim((cat.name || "").padEnd(22))} ${chalk.dim(`${cat.score}/${cat.maxScore}`)}`,
|
|
105
115
|
);
|
|
106
116
|
}
|
|
107
117
|
}
|
|
108
118
|
|
|
109
119
|
const refs = REFERENCE_SCORES[key];
|
|
110
120
|
if (refs) {
|
|
111
|
-
const
|
|
121
|
+
const cmp = Object.entries(refs)
|
|
112
122
|
.sort((a, b) => b[1] - a[1])
|
|
113
123
|
.map(([n, s]) => `${n} ${s}`)
|
|
114
|
-
.join(
|
|
115
|
-
console.log(chalk.dim(`
|
|
124
|
+
.join(" · ");
|
|
125
|
+
console.log(chalk.dim(` vs ${cmp}`));
|
|
116
126
|
}
|
|
117
127
|
|
|
118
128
|
console.log("");
|
package/src/history/index.js
CHANGED
|
@@ -29,7 +29,11 @@ export function loadHistory(historyPath) {
|
|
|
29
29
|
if (existsSync(historyPath)) {
|
|
30
30
|
try {
|
|
31
31
|
return JSON.parse(readFileSync(historyPath, "utf8"));
|
|
32
|
-
} catch {
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.warn(
|
|
34
|
+
`Warning: corrupt history file ${historyPath} — ${err.message}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
33
37
|
}
|
|
34
38
|
return { scans: [] };
|
|
35
39
|
}
|
package/src/scan.js
CHANGED
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
+
import { createInterface } from "readline";
|
|
3
|
+
import { execSync } from "child_process";
|
|
2
4
|
import { runAllBenchmarks, printBenchmarks } from "./benchmark/index.js";
|
|
3
5
|
import { saveResult } from "./history/index.js";
|
|
4
|
-
import { generateDashboard } from "./dashboard/generate.js";
|
|
5
|
-
import { exec } from "child_process";
|
|
6
6
|
|
|
7
7
|
export async function scan(opts) {
|
|
8
|
-
const { url, json } = opts;
|
|
8
|
+
const { url, dir, json } = opts;
|
|
9
9
|
|
|
10
10
|
if (!json) {
|
|
11
|
-
console.log(
|
|
12
|
-
|
|
13
|
-
);
|
|
14
|
-
console.log(chalk.dim(` Scanning ${url}...\n`));
|
|
11
|
+
console.log(chalk.bold("\n aeo-ready") + chalk.dim(` — ${url}\n`));
|
|
12
|
+
console.log(chalk.dim(" Checking agentic-seo · Cloudflare · Fern...\n"));
|
|
15
13
|
}
|
|
16
14
|
|
|
17
|
-
const benchmarks = await runAllBenchmarks(url);
|
|
15
|
+
const benchmarks = await runAllBenchmarks(url, dir);
|
|
18
16
|
const scores = collectScores(benchmarks);
|
|
19
17
|
const averageScore =
|
|
20
18
|
scores.length > 0
|
|
@@ -35,10 +33,8 @@ export async function scan(opts) {
|
|
|
35
33
|
const baseDir = process.cwd();
|
|
36
34
|
await saveResult(result, baseDir);
|
|
37
35
|
|
|
38
|
-
if (!json) {
|
|
39
|
-
|
|
40
|
-
console.log(chalk.dim(` Dashboard: ${dashPath}\n`));
|
|
41
|
-
openInBrowser(dashPath);
|
|
36
|
+
if (!json && averageScore < 100 && process.stdin.isTTY) {
|
|
37
|
+
await promptFix(result, dir);
|
|
42
38
|
}
|
|
43
39
|
|
|
44
40
|
return result;
|
|
@@ -73,50 +69,97 @@ function printReport(result) {
|
|
|
73
69
|
: averageScore >= 50
|
|
74
70
|
? chalk.yellow
|
|
75
71
|
: chalk.red;
|
|
72
|
+
|
|
73
|
+
console.log(chalk.dim(" " + "─".repeat(50)));
|
|
76
74
|
console.log(
|
|
77
|
-
chalk.
|
|
78
|
-
);
|
|
79
|
-
console.log(
|
|
80
|
-
` Average across all sources: ${gc.bold(`${averageScore}/100`)}\n`,
|
|
75
|
+
` ${chalk.bold("Overall")}${" ".repeat(37)}${gc.bold(`${averageScore}/100`)}\n`,
|
|
81
76
|
);
|
|
82
77
|
|
|
78
|
+
printNextSteps(result);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function printNextSteps(result) {
|
|
82
|
+
const { benchmarks, averageScore } = result;
|
|
83
|
+
const steps = [];
|
|
84
|
+
|
|
83
85
|
if (averageScore < 80) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
chalk.dim(" npx agentic-seo init") +
|
|
87
|
-
" scaffold llms.txt, AGENTS.md, skill.md",
|
|
88
|
-
);
|
|
86
|
+
steps.push(["npx agentic-seo init", "scaffold llms.txt, AGENTS.md"]);
|
|
87
|
+
}
|
|
89
88
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
89
|
+
const cfFails =
|
|
90
|
+
benchmarks.cloudflare?.checks?.filter((c) => c.status === "fail") || [];
|
|
91
|
+
if (cfFails.length > 0) {
|
|
92
|
+
steps.push([
|
|
93
|
+
`Cloudflare: ${cfFails.length} failing`,
|
|
94
|
+
"see isitagentready.com",
|
|
95
|
+
]);
|
|
96
|
+
}
|
|
98
97
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
chalk.dim(` — run npx afdocs ${result.url}`),
|
|
107
|
-
);
|
|
108
|
-
}
|
|
98
|
+
const fernFails =
|
|
99
|
+
benchmarks.fern?.checks?.filter(
|
|
100
|
+
(c) => c.status === "fail" || c.status === "warn",
|
|
101
|
+
) || [];
|
|
102
|
+
if (fernFails.length > 0) {
|
|
103
|
+
steps.push([`npx afdocs ${result.url}`, `${fernFails.length} Fern issues`]);
|
|
104
|
+
}
|
|
109
105
|
|
|
110
|
-
|
|
106
|
+
steps.push([
|
|
107
|
+
"npx skills add katrinalaszlo/agent-serve",
|
|
108
|
+
"make your product agent-ready",
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
if (steps.length > 0) {
|
|
112
|
+
console.log(chalk.bold(" Next steps\n"));
|
|
113
|
+
const maxCmd = Math.max(...steps.map(([cmd]) => cmd.length));
|
|
114
|
+
for (const [cmd, desc] of steps) {
|
|
115
|
+
console.log(` ${cmd.padEnd(maxCmd + 4)}${chalk.dim(desc)}`);
|
|
116
|
+
}
|
|
117
|
+
console.log("");
|
|
111
118
|
}
|
|
112
119
|
}
|
|
113
120
|
|
|
114
|
-
function
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
121
|
+
function ask(question) {
|
|
122
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
rl.question(question, (answer) => {
|
|
125
|
+
rl.close();
|
|
126
|
+
resolve(answer.trim().toLowerCase());
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function promptFix(result, dir) {
|
|
132
|
+
const answer = await ask(chalk.bold(" Fix now? ") + chalk.dim("[y/N] "));
|
|
133
|
+
if (answer !== "y" && answer !== "yes") return;
|
|
134
|
+
|
|
135
|
+
console.log("");
|
|
136
|
+
|
|
137
|
+
const targetDir = dir || ".";
|
|
138
|
+
|
|
139
|
+
console.log(chalk.dim(` Running: npx agentic-seo init ${targetDir}\n`));
|
|
140
|
+
try {
|
|
141
|
+
execSync(`npx agentic-seo init ${targetDir}`, {
|
|
142
|
+
stdio: "inherit",
|
|
143
|
+
});
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.log(chalk.red(`\n agentic-seo init failed: ${err.message}\n`));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const fernFails =
|
|
149
|
+
result.benchmarks.fern?.checks?.filter(
|
|
150
|
+
(c) => c.status === "fail" || c.status === "warn",
|
|
151
|
+
) || [];
|
|
152
|
+
if (fernFails.length > 0) {
|
|
153
|
+
console.log(chalk.dim(`\n Running: npx afdocs ${result.url}\n`));
|
|
154
|
+
try {
|
|
155
|
+
execSync(`npx afdocs ${result.url}`, { stdio: "inherit" });
|
|
156
|
+
} catch (err) {
|
|
157
|
+
console.log(chalk.red(`\n afdocs failed: ${err.message}\n`));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
console.log(
|
|
162
|
+
chalk.dim("\n Re-scan to verify: ") +
|
|
163
|
+
`npx aeo-ready scan ${result.url}${dir ? ` --dir ${dir}` : ""}\n`,
|
|
164
|
+
);
|
|
122
165
|
}
|
package/src/utils/fetch.js
CHANGED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
2
|
-
import { join } from "path";
|
|
3
|
-
import { getHistory } from "../history/index.js";
|
|
4
|
-
import { renderOverallScore } from "./sections/overall-score.js";
|
|
5
|
-
import { renderHistoryTable } from "./sections/history-table.js";
|
|
6
|
-
import { renderTrendChart } from "./sections/trend-chart.js";
|
|
7
|
-
import { renderBenchmarkDetails } from "./sections/benchmark-details.js";
|
|
8
|
-
|
|
9
|
-
const DASHBOARD_DIR = ".aeo-ready";
|
|
10
|
-
const DASHBOARD_FILE = "dashboard.html";
|
|
11
|
-
|
|
12
|
-
export async function generateDashboard(scanResult, dir, opts = {}) {
|
|
13
|
-
const dashDir = join(dir, DASHBOARD_DIR);
|
|
14
|
-
if (!existsSync(dashDir)) mkdirSync(dashDir, { recursive: true });
|
|
15
|
-
|
|
16
|
-
const dashPath = join(dashDir, DASHBOARD_FILE);
|
|
17
|
-
const history = getHistory(dir);
|
|
18
|
-
|
|
19
|
-
const sections = {
|
|
20
|
-
"overall-score": renderOverallScore(scanResult),
|
|
21
|
-
"benchmark-details": renderBenchmarkDetails(scanResult),
|
|
22
|
-
"trend-chart": renderTrendChart(history),
|
|
23
|
-
"history-table": renderHistoryTable(history),
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
let html;
|
|
27
|
-
if (existsSync(dashPath)) {
|
|
28
|
-
html = readFileSync(dashPath, "utf8");
|
|
29
|
-
for (const [name, content] of Object.entries(sections)) {
|
|
30
|
-
html = replaceSection(html, name, content);
|
|
31
|
-
}
|
|
32
|
-
} else {
|
|
33
|
-
html = buildFullDashboard(sections, scanResult);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
writeFileSync(dashPath, html);
|
|
37
|
-
return dashPath;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function replaceSection(html, name, content) {
|
|
41
|
-
const regex = new RegExp(
|
|
42
|
-
`(<!-- SECTION:${name} -->)[\\s\\S]*?(<!-- /SECTION:${name} -->)`,
|
|
43
|
-
"g",
|
|
44
|
-
);
|
|
45
|
-
if (regex.test(html)) {
|
|
46
|
-
return html.replace(regex, `$1\n${content}\n$2`);
|
|
47
|
-
}
|
|
48
|
-
return html;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function buildFullDashboard(sections, scanResult) {
|
|
52
|
-
const timestamp = new Date().toISOString().slice(0, 10);
|
|
53
|
-
|
|
54
|
-
return `<!DOCTYPE html>
|
|
55
|
-
<html lang="en">
|
|
56
|
-
<head>
|
|
57
|
-
<meta charset="UTF-8">
|
|
58
|
-
<title>aeo-ready — AEO Benchmark Dashboard</title>
|
|
59
|
-
<style>
|
|
60
|
-
${CSS}
|
|
61
|
-
</style>
|
|
62
|
-
</head>
|
|
63
|
-
<body>
|
|
64
|
-
|
|
65
|
-
<nav>
|
|
66
|
-
<h2>aeo-ready</h2>
|
|
67
|
-
<a href="#overall" class="section-head">Scores</a>
|
|
68
|
-
<a href="#details" class="section-head">Benchmark Details</a>
|
|
69
|
-
<a href="#trends" class="section-head">Trends</a>
|
|
70
|
-
<a href="#history" class="section-head">History</a>
|
|
71
|
-
</nav>
|
|
72
|
-
|
|
73
|
-
<main>
|
|
74
|
-
|
|
75
|
-
<h1>AEO Benchmark Dashboard</h1>
|
|
76
|
-
<p class="subtitle">${scanResult.url} · ${timestamp}</p>
|
|
77
|
-
|
|
78
|
-
<!-- SECTION:overall-score -->
|
|
79
|
-
${sections["overall-score"]}
|
|
80
|
-
<!-- /SECTION:overall-score -->
|
|
81
|
-
|
|
82
|
-
<!-- SECTION:benchmark-details -->
|
|
83
|
-
${sections["benchmark-details"]}
|
|
84
|
-
<!-- /SECTION:benchmark-details -->
|
|
85
|
-
|
|
86
|
-
<!-- SECTION:trend-chart -->
|
|
87
|
-
${sections["trend-chart"]}
|
|
88
|
-
<!-- /SECTION:trend-chart -->
|
|
89
|
-
|
|
90
|
-
<!-- SECTION:history-table -->
|
|
91
|
-
${sections["history-table"]}
|
|
92
|
-
<!-- /SECTION:history-table -->
|
|
93
|
-
|
|
94
|
-
</main>
|
|
95
|
-
</body>
|
|
96
|
-
</html>`;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const CSS = `* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
100
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0d1117; color: #c9d1d9; display: flex; }
|
|
101
|
-
nav { position: fixed; top: 0; left: 0; width: 200px; height: 100vh; background: #010409; border-right: 1px solid #21262d; padding: 0; overflow-y: auto; z-index: 10; }
|
|
102
|
-
nav h2 { color: #f0f6fc; font-size: 13px; letter-spacing: 0.02em; padding: 20px 16px 14px; border-bottom: 1px solid #21262d; margin: 0 0 8px; }
|
|
103
|
-
nav a { display: block; padding: 6px 16px; font-size: 12px; color: #8b949e; text-decoration: none; transition: color 0.15s; }
|
|
104
|
-
nav a:hover { color: #f0f6fc; }
|
|
105
|
-
nav a.section-head { color: #c9d1d9; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; margin-top: 12px; }
|
|
106
|
-
main { margin-left: 200px; padding: 32px 40px; max-width: 900px; width: 100%; }
|
|
107
|
-
h1 { color: #f0f6fc; font-size: 24px; margin-bottom: 4px; }
|
|
108
|
-
.subtitle { color: #8b949e; font-size: 13px; margin-bottom: 32px; }
|
|
109
|
-
h2 { color: #f0f6fc; font-size: 18px; margin-top: 40px; margin-bottom: 16px; padding-top: 16px; border-top: 1px solid #21262d; }
|
|
110
|
-
h3 { color: #f0f6fc; font-size: 14px; margin-top: 16px; margin-bottom: 8px; }
|
|
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; }
|
|
118
|
-
.grade-a { color: #3fb950; } .grade-b { color: #79c0ff; } .grade-c { color: #d29922; } .grade-d { color: #f85149; } .grade-f { color: #f85149; }
|
|
119
|
-
.bar-a { background: #3fb950; } .bar-b { background: #79c0ff; } .bar-c { background: #d29922; } .bar-d { background: #f85149; } .bar-f { background: #f85149; }
|
|
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; }
|
|
122
|
-
.check { font-size: 12px; padding: 2px 0 2px 16px; color: #8b949e; }
|
|
123
|
-
.check.pass { color: #3fb950; }
|
|
124
|
-
.check.fail { color: #f85149; }
|
|
125
|
-
.compare { font-size: 11px; color: #8b949e; margin-top: 8px; padding-top: 8px; border-top: 1px solid #21262d; }
|
|
126
|
-
table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
|
127
|
-
th, td { text-align: left; padding: 8px 12px; font-size: 12px; border-bottom: 1px solid #21262d; }
|
|
128
|
-
th { color: #8b949e; font-weight: 600; }
|
|
129
|
-
.trend { color: #3fb950; } .regression { color: #f85149; }
|
|
130
|
-
svg { display: block; margin: 16px 0; }`;
|
|
@@ -1,79 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
export function renderHistoryTable(history) {
|
|
2
|
-
if (!history.scans || history.scans.length === 0) {
|
|
3
|
-
return `<h2 id="history">Scan History</h2>\n<p style="color:#8b949e;font-size:13px;">No scan history yet.</p>`;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
const scans = [...history.scans].reverse().slice(0, 20);
|
|
7
|
-
|
|
8
|
-
let html = `<h2 id="history">Scan History</h2>\n<table>\n<tr><th>Date</th><th>agentic-seo</th><th>Cloudflare</th><th>Fern</th><th>Avg</th><th>Delta</th></tr>`;
|
|
9
|
-
|
|
10
|
-
for (let i = 0; i < scans.length; i++) {
|
|
11
|
-
const s = scans[i];
|
|
12
|
-
const prev = scans[i + 1];
|
|
13
|
-
const delta = prev ? s.averageScore - prev.averageScore : 0;
|
|
14
|
-
const deltaStr =
|
|
15
|
-
delta > 0
|
|
16
|
-
? `<span class="trend">+${delta}</span>`
|
|
17
|
-
: delta < 0
|
|
18
|
-
? `<span class="regression">${delta}</span>`
|
|
19
|
-
: `<span style="color:#8b949e">—</span>`;
|
|
20
|
-
|
|
21
|
-
const date = s.timestamp ? s.timestamp.slice(0, 10) : "—";
|
|
22
|
-
const cf =
|
|
23
|
-
s.cloudflare != null && s.cloudflareMax
|
|
24
|
-
? `${s.cloudflare}/${s.cloudflareMax}`
|
|
25
|
-
: "—";
|
|
26
|
-
|
|
27
|
-
html += `\n<tr>
|
|
28
|
-
<td>${date}</td>
|
|
29
|
-
<td>${s.agenticSeo ?? "—"}</td>
|
|
30
|
-
<td>${cf}</td>
|
|
31
|
-
<td>${s.fern ?? "—"}</td>
|
|
32
|
-
<td>${s.averageScore ?? "—"}</td>
|
|
33
|
-
<td>${deltaStr}</td>
|
|
34
|
-
</tr>`;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
html += `\n</table>`;
|
|
38
|
-
return html;
|
|
39
|
-
}
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
export function renderOverallScore(result) {
|
|
2
|
-
const { benchmarks, averageScore } = result;
|
|
3
|
-
|
|
4
|
-
let html = `<div id="overall" class="scores">`;
|
|
5
|
-
|
|
6
|
-
if (benchmarks.agenticSeo?.available) {
|
|
7
|
-
html += scoreCard(
|
|
8
|
-
"agentic-seo",
|
|
9
|
-
benchmarks.agenticSeo.score,
|
|
10
|
-
100,
|
|
11
|
-
benchmarks.agenticSeo.grade,
|
|
12
|
-
);
|
|
13
|
-
}
|
|
14
|
-
if (benchmarks.cloudflare?.available) {
|
|
15
|
-
html += scoreCard(
|
|
16
|
-
"Cloudflare",
|
|
17
|
-
benchmarks.cloudflare.score,
|
|
18
|
-
benchmarks.cloudflare.maxScore,
|
|
19
|
-
benchmarks.cloudflare.grade,
|
|
20
|
-
);
|
|
21
|
-
}
|
|
22
|
-
if (benchmarks.fern?.available) {
|
|
23
|
-
html += scoreCard(
|
|
24
|
-
"Fern",
|
|
25
|
-
benchmarks.fern.score,
|
|
26
|
-
100,
|
|
27
|
-
benchmarks.fern.grade,
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
html += `</div>`;
|
|
32
|
-
|
|
33
|
-
const gc =
|
|
34
|
-
averageScore >= 80 ? "grade-a" : averageScore >= 50 ? "grade-c" : "grade-f";
|
|
35
|
-
html += `<div style="text-align:center;margin:16px 0;font-size:14px;color:#8b949e;">Average across sources: <strong class="${gc}">${averageScore}/100</strong></div>`;
|
|
36
|
-
|
|
37
|
-
return html;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function scoreCard(name, score, maxScore, grade) {
|
|
41
|
-
const pct = maxScore > 0 ? Math.round((score / maxScore) * 100) : 0;
|
|
42
|
-
const gc = grade
|
|
43
|
-
? grade.toLowerCase()
|
|
44
|
-
: pct >= 80
|
|
45
|
-
? "a"
|
|
46
|
-
: pct >= 65
|
|
47
|
-
? "b"
|
|
48
|
-
: pct >= 50
|
|
49
|
-
? "c"
|
|
50
|
-
: pct >= 35
|
|
51
|
-
? "d"
|
|
52
|
-
: "f";
|
|
53
|
-
|
|
54
|
-
return `
|
|
55
|
-
<div class="score-card">
|
|
56
|
-
<div class="name">${esc(name)}</div>
|
|
57
|
-
<div class="grade grade-${gc}">${score}/${maxScore}</div>
|
|
58
|
-
<div class="number">${grade || ""}</div>
|
|
59
|
-
<div class="bar"><div class="bar-fill bar-${gc}" style="width:${pct}%"></div></div>
|
|
60
|
-
</div>`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function esc(s) {
|
|
64
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
65
|
-
}
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
export function renderTrendChart(history) {
|
|
2
|
-
if (!history.scans || history.scans.length < 2) {
|
|
3
|
-
return `<h2 id="trends">Score Trends</h2>\n<p style="color:#8b949e;font-size:13px;">Need at least 2 scans for trend data.</p>`;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
const scans = history.scans.slice(-20);
|
|
7
|
-
const width = 700;
|
|
8
|
-
const height = 200;
|
|
9
|
-
const pad = { top: 20, right: 20, bottom: 30, left: 40 };
|
|
10
|
-
const chartW = width - pad.left - pad.right;
|
|
11
|
-
const chartH = height - pad.top - pad.bottom;
|
|
12
|
-
const xStep = scans.length > 1 ? chartW / (scans.length - 1) : chartW;
|
|
13
|
-
|
|
14
|
-
function toY(value) {
|
|
15
|
-
return pad.top + chartH - (value / 100) * chartH;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function polyline(values, color) {
|
|
19
|
-
const pts = values.map(
|
|
20
|
-
(v, i) =>
|
|
21
|
-
`${i === 0 ? "M" : "L"} ${(pad.left + i * xStep).toFixed(1)} ${toY(v ?? 0).toFixed(1)}`,
|
|
22
|
-
);
|
|
23
|
-
return `<path d="${pts.join(" ")}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const agenticSeo = scans.map((s) => s.agenticSeo ?? 0);
|
|
27
|
-
const cloudflare = scans.map((s) =>
|
|
28
|
-
s.cloudflare != null && s.cloudflareMax
|
|
29
|
-
? Math.round((s.cloudflare / s.cloudflareMax) * 100)
|
|
30
|
-
: 0,
|
|
31
|
-
);
|
|
32
|
-
const fern = scans.map((s) => s.fern ?? 0);
|
|
33
|
-
|
|
34
|
-
const grid = [0, 25, 50, 75, 100]
|
|
35
|
-
.map((v) => {
|
|
36
|
-
const y = toY(v);
|
|
37
|
-
return `<line x1="${pad.left}" y1="${y}" x2="${width - pad.right}" y2="${y}" stroke="#21262d"/><text x="${pad.left - 8}" y="${y + 4}" text-anchor="end" fill="#8b949e" font-size="10">${v}</text>`;
|
|
38
|
-
})
|
|
39
|
-
.join("\n");
|
|
40
|
-
|
|
41
|
-
const labels = scans
|
|
42
|
-
.map((s, i) => {
|
|
43
|
-
if (scans.length <= 10 || i % Math.ceil(scans.length / 8) === 0) {
|
|
44
|
-
const x = pad.left + i * xStep;
|
|
45
|
-
const label = s.timestamp ? s.timestamp.slice(5, 10) : "";
|
|
46
|
-
return `<text x="${x}" y="${height - 5}" text-anchor="middle" fill="#8b949e" font-size="10">${label}</text>`;
|
|
47
|
-
}
|
|
48
|
-
return "";
|
|
49
|
-
})
|
|
50
|
-
.join("\n");
|
|
51
|
-
|
|
52
|
-
return `<h2 id="trends">Score Trends</h2>
|
|
53
|
-
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
54
|
-
${grid}
|
|
55
|
-
${labels}
|
|
56
|
-
${polyline(agenticSeo, "#f85149")}
|
|
57
|
-
${polyline(cloudflare, "#3fb950")}
|
|
58
|
-
${polyline(fern, "#79c0ff")}
|
|
59
|
-
<text x="${width - pad.right}" y="${pad.top - 5}" text-anchor="end" fill="#f85149" font-size="10">agentic-seo</text>
|
|
60
|
-
<text x="${width - pad.right - 90}" y="${pad.top - 5}" text-anchor="end" fill="#3fb950" font-size="10">Cloudflare</text>
|
|
61
|
-
<text x="${width - pad.right - 170}" y="${pad.top - 5}" text-anchor="end" fill="#79c0ff" font-size="10">Fern</text>
|
|
62
|
-
</svg>`;
|
|
63
|
-
}
|