Skip to content

Commit e877c5d

Browse files
committed
add diff_pages tool and optional HTML scorecard for audit_page
- new src/tools/diff-pages.ts (Tool 14): parallel auditPage on two URLs, diffs all 8 dimension scores, surfaces missing_in_a/b via finding comparison, returns prioritized fix_recommendations_for_a; registered in src/index.ts - audit_page gains optional generate_report flag: when true, returns a self-contained HTML scorecard (dark card layout, CSS-only progress bars for all 8 dimensions, top-blocker cards with impact badges); rendered by new src/lib/scorecard-html.ts helper, zero new runtime deps - README updated to "All 14 tools" with diff_pages row - tests/smoke.test.ts: network smoke for diff_pages, plus default-absent + generate_report=true assertions for audit_page
1 parent 5360e05 commit e877c5d

6 files changed

Lines changed: 470 additions & 1 deletion

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ Restart Claude Desktop. Any MCP client that supports stdio transport works - sam
9797
---
9898

9999
<details>
100-
<summary>All 13 tools</summary>
100+
<summary>All 14 tools</summary>
101101

102102
| Tool | Purpose |
103103
|------|---------|
@@ -114,6 +114,7 @@ Restart Claude Desktop. Any MCP client that supports stdio transport works - sam
114114
| `rewrite_for_aeo` | Rewrite content for Answer Engine Optimization (BLUF structure, FAQ format, schema additions). |
115115
| `rewrite_for_geo` | Rewrite content for Generative Engine Optimization (entity definitions, comparison tables, synthesis-ready structure). |
116116
| `extract_entities` | Extract named entities, `sameAs` links, and citation-density score from a page's content and structured data. |
117+
| `diff_pages` | Compare two URLs for AI citation-worthiness: side-by-side dimension scores, gap analysis, and prioritized fix recommendations for url_a. |
117118

118119
Environment variables: see [ENV.md](./ENV.md).
119120

src/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { scoreCitationWorthiness } from "./tools/score-citation-worthiness.js";
1919
import { rewriteForAeo } from "./tools/rewrite-for-aeo.js";
2020
import { rewriteForGeo } from "./tools/rewrite-for-geo.js";
2121
import { extractEntities } from "./tools/extract-entities.js";
22+
import { diffPages, diffPagesInputSchema } from "./tools/diff-pages.js";
2223
import type { ToolError } from "./types.js";
2324
import { ToolFetchError } from "./lib/fetch.js";
2425

@@ -387,6 +388,20 @@ server.tool(
387388
}
388389
);
389390

391+
// --- Tool 14: diff_pages ---
392+
server.tool(
393+
"diff_pages",
394+
[
395+
"Compare two URLs for AI citation-worthiness and return a structured breakdown of which page is more likely to be cited and why. Typical use: your page (url_a) vs a competitor's page (url_b).",
396+
"Read-only. Runs audit_page on both URLs in parallel (2 HTTP fetches per URL), then diffs dimension_scores and findings. No new fetch logic beyond what audit_page already does.",
397+
"Deterministic, rule-based; no LLM calls. Same two URLs return the same comparison on repeated runs.",
398+
"When to use: competitive gap analysis - understand exactly which dimensions (schema, structure, robots, entity density, freshness, technical, authority, sitemap) put a competitor ahead, and get prioritized fix_recommendations_for_a to close the gap. For a single-URL audit, use audit_page. For overall scoring of one page, use score_citation_worthiness.",
399+
"Capped at 2 URLs per call. Heuristic verdict - does not claim to know what AI assistants actually cite; verdict matches audit_page's existing rubric.",
400+
].join("\n\n"),
401+
diffPagesInputSchema.shape,
402+
async (input) => wrapHandler(() => diffPages(input))
403+
);
404+
390405
// --- Start server ---
391406
async function main(): Promise<void> {
392407
const transport = new StdioServerTransport();

src/lib/scorecard-html.ts

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Render a standalone HTML scorecard from an audit_page result.
2+
// No external dependencies - inline CSS only.
3+
4+
import type { CitationVerdict } from "../tools/audit-page.js";
5+
6+
interface ScorecardInput {
7+
url: string;
8+
fetched_at: string;
9+
score: number;
10+
grade: "A" | "B" | "C" | "D" | "F";
11+
citation_verdict: CitationVerdict;
12+
dimension_scores: {
13+
schema: number;
14+
robots: number;
15+
technical: number;
16+
freshness: number;
17+
structure: number;
18+
authority: number;
19+
entity_density: number;
20+
sitemap: number;
21+
};
22+
}
23+
24+
const DIMENSION_LABELS: Record<string, string> = {
25+
schema: "Schema",
26+
technical: "Technical",
27+
structure: "Structure",
28+
robots: "Robots",
29+
freshness: "Freshness",
30+
authority: "Authority",
31+
entity_density: "Entity Density",
32+
sitemap: "Sitemap",
33+
};
34+
35+
function escHtml(str: string): string {
36+
return str
37+
.replace(/&/g, "&amp;")
38+
.replace(/</g, "&lt;")
39+
.replace(/>/g, "&gt;")
40+
.replace(/"/g, "&quot;");
41+
}
42+
43+
function barColor(score: number): string {
44+
if (score >= 75) return "#22c55e";
45+
if (score >= 50) return "#f59e0b";
46+
return "#ef4444";
47+
}
48+
49+
function verdictColor(verdict: string): string {
50+
if (verdict === "likely") return "#22c55e";
51+
if (verdict === "marginal") return "#f59e0b";
52+
return "#ef4444";
53+
}
54+
55+
function gradeColor(grade: string): string {
56+
if (grade === "A") return "#22c55e";
57+
if (grade === "B") return "#84cc16";
58+
if (grade === "C") return "#f59e0b";
59+
if (grade === "D") return "#f97316";
60+
return "#ef4444";
61+
}
62+
63+
export function renderScorecardHtml(input: ScorecardInput): string {
64+
const { url, fetched_at, score, grade, citation_verdict, dimension_scores } = input;
65+
66+
const formattedDate = new Date(fetched_at).toUTCString();
67+
const verdictLabel =
68+
citation_verdict.will_ai_cite === "likely"
69+
? "Likely to be cited"
70+
: citation_verdict.will_ai_cite === "marginal"
71+
? "Marginally citable"
72+
: "Unlikely to be cited";
73+
74+
const dimensionRows = Object.entries(dimension_scores)
75+
.map(([key, val]) => {
76+
const label = DIMENSION_LABELS[key] ?? key;
77+
const color = barColor(val);
78+
return `
79+
<div class="dim-row">
80+
<span class="dim-label">${escHtml(label)}</span>
81+
<div class="bar-track">
82+
<div class="bar-fill" style="width:${val}%;background:${color};"></div>
83+
</div>
84+
<span class="dim-score">${val}</span>
85+
</div>`;
86+
})
87+
.join("");
88+
89+
const blockerItems = citation_verdict.top_3_blockers
90+
.map((b, i) => {
91+
const impactBadge = b.estimated_impact
92+
? `<span class="badge badge-${b.estimated_impact}">${escHtml(b.estimated_impact)}</span>`
93+
: "";
94+
return `
95+
<div class="blocker">
96+
<div class="blocker-header">
97+
<span class="blocker-num">${i + 1}</span>
98+
<span class="blocker-cat">${escHtml(b.category)}</span>
99+
${impactBadge}
100+
</div>
101+
<p class="blocker-msg">${escHtml(b.message)}</p>
102+
<p class="blocker-fix"><strong>Fix:</strong> ${escHtml(b.fix)}</p>
103+
</div>`;
104+
})
105+
.join("");
106+
107+
const noBlockers =
108+
citation_verdict.top_3_blockers.length === 0
109+
? `<p class="no-blockers">No critical blockers found.</p>`
110+
: "";
111+
112+
return `<!DOCTYPE html>
113+
<html lang="en">
114+
<head>
115+
<meta charset="UTF-8">
116+
<meta name="viewport" content="width=device-width,initial-scale=1">
117+
<title>AI-SEO Scorecard - ${escHtml(url)}</title>
118+
<style>
119+
*{box-sizing:border-box;margin:0;padding:0}
120+
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh;padding:2rem 1rem}
121+
.card{max-width:700px;margin:0 auto;background:#1e293b;border-radius:12px;overflow:hidden;box-shadow:0 4px 32px rgba(0,0,0,.4)}
122+
.header{padding:2rem;border-bottom:1px solid #334155}
123+
.header-url{font-size:.85rem;color:#94a3b8;word-break:break-all;margin-bottom:.5rem}
124+
.header-ts{font-size:.78rem;color:#64748b}
125+
.grade-row{display:flex;align-items:center;gap:1.5rem;margin-top:1.25rem}
126+
.grade-badge{font-size:3rem;font-weight:800;line-height:1;color:${gradeColor(grade)}}
127+
.score-num{font-size:1.75rem;font-weight:700;color:#f1f5f9}
128+
.score-label{font-size:.8rem;color:#94a3b8;margin-top:.2rem}
129+
.verdict-pill{display:inline-block;padding:.35rem .9rem;border-radius:999px;font-size:.85rem;font-weight:600;background:${verdictColor(citation_verdict.will_ai_cite)}22;color:${verdictColor(citation_verdict.will_ai_cite)};border:1px solid ${verdictColor(citation_verdict.will_ai_cite)}44;margin-top:.5rem}
130+
.summary{font-size:.9rem;color:#cbd5e1;margin-top:.75rem;line-height:1.5}
131+
.section{padding:1.5rem 2rem}
132+
.section + .section{border-top:1px solid #334155}
133+
.section-title{font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#64748b;margin-bottom:1rem}
134+
.dim-row{display:grid;grid-template-columns:110px 1fr 36px;align-items:center;gap:.75rem;margin-bottom:.6rem}
135+
.dim-label{font-size:.82rem;color:#94a3b8;text-align:right}
136+
.bar-track{height:8px;background:#0f172a;border-radius:4px;overflow:hidden}
137+
.bar-fill{height:100%;border-radius:4px;transition:width .3s}
138+
.dim-score{font-size:.82rem;font-weight:600;color:#e2e8f0;text-align:right}
139+
.blocker{background:#0f172a;border-radius:8px;padding:1rem;margin-bottom:.75rem;border-left:3px solid #ef4444}
140+
.blocker:last-child{margin-bottom:0}
141+
.blocker-header{display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem}
142+
.blocker-num{width:22px;height:22px;border-radius:50%;background:#ef4444;color:#fff;font-size:.75rem;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0}
143+
.blocker-cat{font-size:.8rem;font-weight:600;color:#cbd5e1;text-transform:capitalize}
144+
.badge{padding:.15rem .5rem;border-radius:4px;font-size:.7rem;font-weight:600;text-transform:uppercase}
145+
.badge-high{background:#ef444422;color:#ef4444}
146+
.badge-medium{background:#f59e0b22;color:#f59e0b}
147+
.badge-low{background:#22c55e22;color:#22c55e}
148+
.blocker-msg{font-size:.82rem;color:#94a3b8;margin-bottom:.35rem;line-height:1.4}
149+
.blocker-fix{font-size:.82rem;color:#64748b;line-height:1.4}
150+
.blocker-fix strong{color:#94a3b8}
151+
.no-blockers{font-size:.9rem;color:#22c55e}
152+
.footer{padding:1rem 2rem;text-align:center;font-size:.75rem;color:#475569;border-top:1px solid #334155}
153+
.footer a{color:#6366f1;text-decoration:none}
154+
.footer a:hover{text-decoration:underline}
155+
</style>
156+
</head>
157+
<body>
158+
<div class="card">
159+
<div class="header">
160+
<div class="header-url">${escHtml(url)}</div>
161+
<div class="header-ts">Audited ${escHtml(formattedDate)}</div>
162+
<div class="grade-row">
163+
<div>
164+
<div class="grade-badge">${escHtml(grade)}</div>
165+
</div>
166+
<div>
167+
<div class="score-num">${score}<span style="font-size:1rem;color:#64748b">/100</span></div>
168+
<div class="score-label">AI Citation Score</div>
169+
</div>
170+
</div>
171+
<div class="verdict-pill">${escHtml(verdictLabel)}</div>
172+
<p class="summary">${escHtml(citation_verdict.one_line_summary)}</p>
173+
</div>
174+
175+
<div class="section">
176+
<div class="section-title">Dimension Scores</div>
177+
${dimensionRows}
178+
</div>
179+
180+
<div class="section">
181+
<div class="section-title">Top Blockers</div>
182+
${blockerItems}${noBlockers}
183+
</div>
184+
185+
<div class="footer">
186+
Generated by <a href="https://github.com/AutomateLab-tech/ai-seo" target="_blank" rel="noopener">AI Citation Toolkit</a> - github.com/AutomateLab-tech/ai-seo
187+
</div>
188+
</div>
189+
</body>
190+
</html>`;
191+
}

src/tools/audit-page.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { checkRobots } from "./check-robots.js";
1111
import { checkSitemap } from "./check-sitemap.js";
1212
import { scoreAiOverviewEligibility } from "./score-ai-overview-eligibility.js";
1313
import { freshnessScore, deriveGrade, computeWeightedScore } from "../lib/score.js";
14+
import { renderScorecardHtml } from "../lib/scorecard-html.js";
1415
import type { Finding, AuditResult } from "../types.js";
1516

1617
export const auditPageInputSchema = z.object({
@@ -28,6 +29,11 @@ export const auditPageInputSchema = z.object({
2829
.optional()
2930
.default(true)
3031
.describe("If true (default), the tool checks robots.txt before fetching and skips disallowed paths, returning a robots_blocked finding instead. Set to false ONLY for auditing your own site where you've intentionally blocked crawlers and need the audit to bypass that block."),
32+
generate_report: z
33+
.boolean()
34+
.optional()
35+
.default(false)
36+
.describe("If true, return a standalone HTML scorecard in the `report_html` field. The HTML is self-contained (no external dependencies) and can be saved as a .html file or pasted to Gist/CodePen. Default false to keep audits cheap."),
3137
});
3238

3339
export type AuditPageInput = z.infer<typeof auditPageInputSchema>;
@@ -56,6 +62,7 @@ export interface AuditPageResult extends AuditResult {
5662
sitemap: number;
5763
};
5864
raw_html?: string;
65+
report_html?: string;
5966
}
6067

6168
export async function auditPage(input: AuditPageInput): Promise<AuditPageResult> {
@@ -316,5 +323,16 @@ export async function auditPage(input: AuditPageInput): Promise<AuditPageResult>
316323
output.raw_html = result.body;
317324
}
318325

326+
if (input.generate_report) {
327+
output.report_html = renderScorecardHtml({
328+
url: input.url,
329+
fetched_at,
330+
score,
331+
grade,
332+
citation_verdict,
333+
dimension_scores: dimensionScores,
334+
});
335+
}
336+
319337
return output;
320338
}

0 commit comments

Comments
 (0)