aeo-ready 1.3.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 +58 -31
- package/bin/cli.js +26 -0
- package/package.json +20 -1
- package/src/benchmark/index.js +38 -28
- package/src/history/index.js +5 -1
- package/src/scan.js +89 -46
- 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
|
@@ -15,7 +15,7 @@ Runs every major AEO (Agentic Engine Optimization) benchmark against your site i
|
|
|
15
15
|
| Benchmark | What it checks | Checks |
|
|
16
16
|
|-----------|---------------|--------|
|
|
17
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/
|
|
18
|
+
| **Cloudflare** (isitagentready.com) | Discoverability, content accessibility, bot access, API/MCP/A2A discovery, commerce | 19 |
|
|
19
19
|
| **Fern** (afdocs) | llms.txt quality, markdown availability, page size, content structure, URL stability, auth | 23 |
|
|
20
20
|
|
|
21
21
|
## Usage
|
|
@@ -39,53 +39,80 @@ With --dir: agentic-seo 92/100 (A)
|
|
|
39
39
|
## Output
|
|
40
40
|
|
|
41
41
|
```
|
|
42
|
-
aeo-ready —
|
|
42
|
+
aeo-ready — yoursite.com
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
███████████████░ agentic-seo 92/100 (A)
|
|
44
|
+
agentic-seo ·································· 91/100 A
|
|
47
45
|
✓ Discovery 25/25
|
|
48
|
-
◑ Content Structure
|
|
46
|
+
◑ Content Structure 18/25
|
|
49
47
|
✓ Token Economics 25/25
|
|
50
48
|
✓ Capability Signaling 15/15
|
|
51
|
-
✓ UX Bridge
|
|
52
|
-
|
|
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]
|
|
75
|
+
```
|
|
76
|
+
|
|
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).
|
|
78
|
+
|
|
79
|
+
## CI Mode
|
|
53
80
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
81
|
+
```yaml
|
|
82
|
+
- run: npx aeo-ready scan yoursite.com --dir ./public --threshold 50
|
|
83
|
+
```
|
|
57
84
|
|
|
58
|
-
|
|
59
|
-
+ llms-txt-exists, + rendering-strategy, - content-negotiation...
|
|
60
|
-
compare: Stripe 85 · Supabase 78 · Anthropic 72
|
|
85
|
+
## History
|
|
61
86
|
|
|
62
|
-
|
|
87
|
+
Scores persist in `.aeo-ready/history.json`. Re-scan to track improvement over time.
|
|
63
88
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
Fern: 6 issues — run npx afdocs https://yoursite.com
|
|
89
|
+
```bash
|
|
90
|
+
npx aeo-ready history # show last 10 scans
|
|
67
91
|
```
|
|
68
92
|
|
|
69
|
-
##
|
|
93
|
+
## Programmatic API
|
|
70
94
|
|
|
71
|
-
```
|
|
72
|
-
|
|
95
|
+
```js
|
|
96
|
+
import { scan, getHistory } from "aeo-ready";
|
|
97
|
+
|
|
98
|
+
const result = await scan({ url: "https://yoursite.com", dir: "./public", json: true });
|
|
99
|
+
// result.averageScore, result.benchmarks.agenticSeo, .cloudflare, .fern
|
|
100
|
+
|
|
101
|
+
const history = getHistory(process.cwd());
|
|
102
|
+
// history.scans — array of past scan results
|
|
73
103
|
```
|
|
74
104
|
|
|
75
|
-
##
|
|
105
|
+
## Next step: make your product agent-ready
|
|
76
106
|
|
|
77
|
-
|
|
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
|
|
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):
|
|
83
108
|
|
|
84
|
-
|
|
109
|
+
```bash
|
|
110
|
+
npx skills add katrinalaszlo/agent-serve
|
|
111
|
+
```
|
|
85
112
|
|
|
86
|
-
##
|
|
113
|
+
## Best practices by site type
|
|
87
114
|
|
|
88
|
-
|
|
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.
|
|
89
116
|
|
|
90
117
|
## Author
|
|
91
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";
|
|
@@ -61,4 +62,29 @@ program
|
|
|
61
62
|
}
|
|
62
63
|
});
|
|
63
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
|
+
|
|
64
90
|
program.parse();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aeo-ready",
|
|
3
|
-
"version": "1.3.
|
|
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
|
},
|
package/src/benchmark/index.js
CHANGED
|
@@ -27,6 +27,8 @@ const REFERENCE_SCORES = {
|
|
|
27
27
|
},
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
+
const W = 52;
|
|
31
|
+
|
|
30
32
|
export async function runAllBenchmarks(target, dir) {
|
|
31
33
|
const isUrl = target && target.startsWith("http");
|
|
32
34
|
|
|
@@ -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,17 +1,15 @@
|
|
|
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
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
15
|
const benchmarks = await runAllBenchmarks(url, dir);
|
|
@@ -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
|
-
}
|