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 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
- # agent-web
1
+ # aeo-ready
2
2
 
3
- Is your site AI-ready? One scan, two scorecards, one score.
3
+ AEO benchmark aggregator. One scan, every score.
4
4
 
5
5
  ```bash
6
- npx agent-web scan --url https://your-site.com
6
+ npx aeo-ready scan yoursite.com
7
7
  ```
8
8
 
9
- ## What it measures
9
+ ## What it does
10
10
 
11
- **Agent Readiness** (0-50) Can AI agents discover, parse, and act on your site?
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
- **AI Visibility** (0-50) — Does your content get cited in AI-generated responses?
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
- **Overall: X/100** with letter grade (A-F).
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
- # Audit a live URL
29
- npx agent-web scan --url https://example.com
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
- # Audit current directory (repo mode)
32
- npx agent-web scan
30
+ ### Why `--dir`?
33
31
 
34
- # JSON output for CI pipelines
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
- # Skip benchmark comparison
38
- npx agent-web scan --no-benchmark
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
- agent-webAI readiness audit
45
-
46
- Mode: url | Type: saas
47
-
48
- Overall: C 50/100
49
-
50
- Agent Readiness: 22/50
51
- discovery: 3/12
52
- + AI-friendly meta tags [3]
53
- - llms.txt [0/4]
54
- fix: No llms.txt found.
55
- - robots.txt AI crawlers [0/3]
56
- fix: robots.txt exists but doesn't mention AI crawlers.
57
-
58
- AI Visibility: 28/50
59
- structured Data: 6/12
60
- + FAQ markup [3]
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
42
+ aeo-readyyoursite.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
- ## CI Mode
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
- Add to GitHub Actions:
79
+ ## CI Mode
77
80
 
78
81
  ```yaml
79
- - run: npx agent-web scan --threshold 50
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
- Each scan saves to `.agent-web/history.json`. Gitignored by default.
87
+ Scores persist in `.aeo-ready/history.json`. Re-scan to track improvement over time.
89
88
 
90
- ## Site Type Detection
89
+ ```bash
90
+ npx aeo-ready history # show last 10 scans
91
+ ```
91
92
 
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
93
+ ## Programmatic API
97
94
 
98
- Scoring adjusts by type (e.g., OpenAPI is required for SaaS, N/A for personal sites).
95
+ ```js
96
+ import { scan, getHistory } from "aeo-ready";
99
97
 
100
- ## Coming Soon
98
+ const result = await scan({ url: "https://yoursite.com", dir: "./public", json: true });
99
+ // result.averageScore, result.benchmarks.agenticSeo, .cloudflare, .fern
101
100
 
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
101
+ const history = getHistory(process.cwd());
102
+ // history.scans array of past scan results
103
+ ```
105
104
 
106
- ## Also available as a Claude Code skill
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-web
110
+ npx skills add katrinalaszlo/agent-serve
110
111
  ```
111
112
 
112
- Then use `/agent-web` or `/agent-web https://example.com` inside Claude Code for an interactive audit that also generates missing files.
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("Run all AEO benchmarks against a URL")
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({ url, json: opts.json || false });
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.2.0",
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
- const args =
6
- target && target.startsWith("http")
7
- ? `--url ${target} --json`
8
- : `${target || "."} --json`;
96
+ let scanDir;
9
97
 
10
- const output = execSync(`npx agentic-seo ${args}`, {
11
- timeout: 30000,
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
  }
@@ -27,11 +27,13 @@ const REFERENCE_SCORES = {
27
27
  },
28
28
  };
29
29
 
30
- export async function runAllBenchmarks(target) {
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 >= 80 ? chalk.green : pct >= 50 ? chalk.yellow : chalk.red;
68
- const barW = 16;
69
- const filled = Math.round((pct / 100) * barW);
70
- const bar = color("█".repeat(filled)) + chalk.dim("░".repeat(barW - filled));
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
- for (const check of b.checks) {
78
- if (check.status === "pass") {
79
- console.log(
80
- chalk.green(` + ${check.id}`) +
81
- chalk.dim(check.message ? ` ${check.message.slice(0, 60)}` : ""),
82
- );
83
- } else if (check.status === "fail") {
84
- console.log(
85
- chalk.red(` - ${check.id}`) +
86
- chalk.dim(check.message ? ` ${check.message.slice(0, 60)}` : ""),
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 names = Object.entries(refs)
121
+ const cmp = Object.entries(refs)
112
122
  .sort((a, b) => b[1] - a[1])
113
123
  .map(([n, s]) => `${n} ${s}`)
114
- .join(chalk.dim(" · "));
115
- console.log(chalk.dim(` compare: ${names}`));
124
+ .join(" · ");
125
+ console.log(chalk.dim(` vs ${cmp}`));
116
126
  }
117
127
 
118
128
  console.log("");
@@ -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
- chalk.bold("\n aeo-ready") + chalk.dim(" AEO benchmark aggregator\n"),
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
- const dashPath = await generateDashboard(result, baseDir);
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.dim(" ─────────────────────────────────────────────────\n"),
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
- console.log(chalk.bold(" Fix it:\n"));
85
- console.log(
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
- const cfFails =
91
- benchmarks.cloudflare?.checks?.filter((c) => c.status === "fail") || [];
92
- for (const fail of cfFails.slice(0, 2)) {
93
- console.log(
94
- chalk.dim(` Cloudflare: ${fail.id}`) +
95
- chalk.dim(` — see isitagentready.com for fix`),
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
- const fernFails =
100
- benchmarks.fern?.checks?.filter(
101
- (c) => c.status === "fail" || c.status === "warn",
102
- ) || [];
103
- if (fernFails.length > 0) {
104
- console.log(
105
- chalk.dim(` Fern: ${fernFails.length} issues`) +
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
- console.log(chalk.dim("\n Fix, then re-scan to track improvement.\n"));
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 openInBrowser(filePath) {
115
- const cmd =
116
- process.platform === "darwin"
117
- ? "open"
118
- : process.platform === "win32"
119
- ? "start"
120
- : "xdg-open";
121
- exec(`${cmd} "${filePath}"`, () => {});
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
  }
@@ -1,5 +1,5 @@
1
1
  const DEFAULT_TIMEOUT = 10000;
2
- const USER_AGENT = "agent-web/1.0 (AI readiness auditor)";
2
+ const USER_AGENT = "aeo-ready/1.3 (AEO benchmark aggregator)";
3
3
 
4
4
  export async function fetchUrl(url, opts = {}) {
5
5
  const controller = new AbortController();
@@ -1,7 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "WebSearch"
5
- ]
6
- }
7
- }
@@ -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, "&amp;")
77
- .replace(/</g, "&lt;")
78
- .replace(/>/g, "&gt;");
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
- }