Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ainyc/aeo-audit",
"version": "1.2.2",
"version": "1.3.0",
"description": "The most comprehensive open-source Answer Engine Optimization (AEO) audit tool. Scores websites across 13 ranking factors that determine AI citation.",
"type": "module",
"main": "./dist/index.js",
Expand Down
25 changes: 25 additions & 0 deletions skills/aeo/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ If no mode is provided, default to `audit`.
## Examples

- `audit https://example.com`
- `audit https://example.com --sitemap`
- `audit https://example.com --sitemap --limit 10`
- `audit https://example.com --sitemap --top-issues`
- `fix https://example.com`
- `schema https://example.com`
- `llms https://example.com`
Expand Down Expand Up @@ -81,6 +84,28 @@ Use for broad requests such as "audit this site" or "why am I not being cited?"
- Top fixes
- Metadata such as fetch time and auxiliary file availability

### Sitemap Mode

Use `--sitemap` to audit all pages discovered from the site's sitemap:

```bash
npx @ainyc/aeo-audit@latest "<url>" --sitemap --format json
npx @ainyc/aeo-audit@latest "<url>" --sitemap https://example.com/sitemap.xml --format json
npx @ainyc/aeo-audit@latest "<url>" --sitemap --limit 10 --format json
npx @ainyc/aeo-audit@latest "<url>" --sitemap --top-issues --format json
```

Flags:
- `--sitemap [url]` — auto-discover `/sitemap.xml` or provide an explicit URL
- `--limit <n>` — cap pages audited (sorted by sitemap priority)
- `--top-issues` — skip per-page output, show only cross-cutting patterns

Returns:
- Per-page scores and grades
- Cross-cutting issues (factors failing across multiple pages)
- Aggregate score and grade
- Prioritized fixes ranked by site-wide impact

## Fix

Use when the user wants code changes applied after the audit.
Expand Down
76 changes: 66 additions & 10 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
import { runAeoAudit } from './index.js'
import { runSitemapAudit } from './sitemap.js'
import { isAeoAuditError } from './errors.js'
import { formatJson } from './formatters/json.js'
import { formatMarkdown } from './formatters/markdown.js'
import { formatText } from './formatters/text.js'
import type { AuditReport, RunAeoAuditOptions } from './types.js'
import { formatSitemapJson } from './formatters/json.js'
import { formatMarkdown, formatSitemapMarkdown } from './formatters/markdown.js'
import { formatText, formatSitemapText } from './formatters/text.js'
import type { AuditReport, SitemapAuditReport, SitemapAuditOptions } from './types.js'

const FORMATTERS = {
json: formatJson,
markdown: formatMarkdown,
text: formatText,
}

const SITEMAP_FORMATTERS = {
json: (report: SitemapAuditReport, _topIssuesOnly: boolean) => formatSitemapJson(report),
markdown: (report: SitemapAuditReport, topIssuesOnly: boolean) => formatSitemapMarkdown(report, topIssuesOnly),
text: (report: SitemapAuditReport, topIssuesOnly: boolean) => formatSitemapText(report, topIssuesOnly),
}

type FormatterName = keyof typeof FORMATTERS

interface ParsedArgs {
Expand All @@ -19,6 +27,10 @@ interface ParsedArgs {
factors: string[] | null
includeGeo: boolean
help: boolean
sitemap: boolean
sitemapUrl: string | null
limit: number | null
topIssues: boolean
}

function isFormatterName(value: string): value is FormatterName {
Expand All @@ -27,7 +39,17 @@ function isFormatterName(value: string): value is FormatterName {

function parseArgs(argv: string[]): ParsedArgs {
const args = argv.slice(2)
const result: ParsedArgs = { url: null, format: 'text', factors: null, includeGeo: false, help: false }
const result: ParsedArgs = {
url: null,
format: 'text',
factors: null,
includeGeo: false,
help: false,
sitemap: false,
sitemapUrl: null,
limit: null,
topIssues: false,
}

for (let i = 0; i < args.length; i += 1) {
if (args[i] === '--format' && args[i + 1]) {
Expand All @@ -38,6 +60,21 @@ function parseArgs(argv: string[]): ParsedArgs {
i += 1
} else if (args[i] === '--include-geo') {
result.includeGeo = true
} else if (args[i] === '--sitemap') {
result.sitemap = true
// Check if the next arg is an explicit sitemap URL (not another flag)
if (args[i + 1] && !args[i + 1].startsWith('--')) {
result.sitemapUrl = args[i + 1]
i += 1
}
} else if (args[i] === '--limit' && args[i + 1]) {
const num = parseInt(args[i + 1], 10)
if (Number.isFinite(num) && num > 0) {
result.limit = num
}
i += 1
} else if (args[i] === '--top-issues') {
result.topIssues = true
} else if (args[i] === '--help' || args[i] === '-h') {
result.help = true
} else if (!args[i].startsWith('-')) {
Expand All @@ -56,13 +93,20 @@ Options:
--format <type> Output format: text (default), json, markdown
--factors <list> Comma-separated factor IDs to run (runs all if omitted)
--include-geo Include optional geographic signals factor
--sitemap [url] Audit all pages from sitemap (auto-discovers /sitemap.xml or use explicit URL)
--limit <n> Max pages to audit in sitemap mode (sorted by sitemap priority)
--top-issues In sitemap mode, skip per-page output and show only cross-cutting issues
-h, --help Show this help message

Examples:
aeo-audit https://example.com
aeo-audit https://example.com --format json
aeo-audit https://example.com --factors structured-data,faq-content
aeo-audit https://example.com --include-geo
aeo-audit https://example.com --sitemap
aeo-audit https://example.com --sitemap https://example.com/sitemap.xml
aeo-audit https://example.com --sitemap --limit 10
aeo-audit https://example.com --sitemap --top-issues
`)
}

Expand All @@ -84,15 +128,27 @@ export async function main(argv: string[] = process.argv): Promise<number> {
return 1
}

const formatter = FORMATTERS[args.format]

try {
const options: RunAeoAuditOptions = {
factors: args.factors,
includeGeo: args.includeGeo,
if (args.sitemap) {
const options: SitemapAuditOptions = {
factors: args.factors,
includeGeo: args.includeGeo,
sitemapUrl: args.sitemapUrl ?? undefined,
limit: args.limit ?? undefined,
topIssuesOnly: args.topIssues,
}

const report = await runSitemapAudit(args.url, options)
const sitemapFormatter = SITEMAP_FORMATTERS[args.format]
console.log(sitemapFormatter(report, args.topIssues))
return report.aggregateScore >= 70 ? 0 : 1
}

const report = await runAeoAudit(args.url, options)
const formatter = FORMATTERS[args.format]
const report = await runAeoAudit(args.url, {
factors: args.factors,
includeGeo: args.includeGeo,
})

console.log(formatter(report))
return report.overallScore >= 70 ? 0 : 1
Expand Down
6 changes: 5 additions & 1 deletion src/formatters/json.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { AuditReport } from '../types.js'
import type { AuditReport, SitemapAuditReport } from '../types.js'

export function formatJson(report: AuditReport): string {
return JSON.stringify(report, null, 2)
}

export function formatSitemapJson(report: SitemapAuditReport): string {
return JSON.stringify(report, null, 2)
}
57 changes: 56 additions & 1 deletion src/formatters/markdown.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AuditReport } from '../types.js'
import type { AuditReport, SitemapAuditReport } from '../types.js'

export function formatMarkdown(report: AuditReport): string {
const lines = []
Expand Down Expand Up @@ -55,3 +55,58 @@ export function formatMarkdown(report: AuditReport): string {

return lines.join('\n')
}

export function formatSitemapMarkdown(report: SitemapAuditReport, topIssuesOnly = false): string {
const lines = []

lines.push(`# AEO Sitemap Audit Report`)
lines.push(``)
lines.push(`**Sitemap:** ${report.sitemapUrl}`)
lines.push(`**Aggregate Grade:** ${report.aggregateGrade} (${report.aggregateScore}/100)`)
lines.push(`**Pages:** ${report.pagesAudited} audited, ${report.pagesSkipped} skipped, ${report.pagesDiscovered} discovered`)
lines.push(`**Audited:** ${report.auditedAt}`)
lines.push(``)

if (!topIssuesOnly) {
lines.push(`## Per-Page Scores`)
lines.push(``)
lines.push(`| URL | Score | Grade | Status |`)
lines.push(`|-----|-------|-------|--------|`)

for (const page of report.pages) {
const url = page.url.length > 60 ? page.url.slice(0, 57) + '...' : page.url
if (page.status === 'error') {
lines.push(`| ${url} | - | - | error: ${page.error} |`)
} else {
lines.push(`| ${url} | ${page.overallScore} | ${page.overallGrade} | ${page.status} |`)
}
}

lines.push(``)
}

if (report.crossCuttingIssues.length > 0) {
lines.push(`## Cross-Cutting Issues`)
lines.push(``)
lines.push(`| Factor | Avg Score | Avg Grade | Affected Pages |`)
lines.push(`|--------|-----------|-----------|----------------|`)

for (const issue of report.crossCuttingIssues) {
const pct = Math.round((issue.affectedPages / issue.totalPages) * 100)
lines.push(`| ${issue.factorName} | ${issue.avgScore} | ${issue.avgGrade} | ${issue.affectedPages}/${issue.totalPages} (${pct}%) |`)
}

lines.push(``)
}

if (report.prioritizedFixes.length > 0) {
lines.push(`## Prioritized Fixes (by site-wide impact)`)
lines.push(``)
for (let i = 0; i < report.prioritizedFixes.length; i++) {
lines.push(`${i + 1}. ${report.prioritizedFixes[i]}`)
}
lines.push(``)
}

return lines.join('\n')
}
61 changes: 60 additions & 1 deletion src/formatters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const YELLOW = '\x1b[33m'
const RED = '\x1b[31m'
const CYAN = '\x1b[36m'

import type { AuditReport, ScoredFactor } from '../types.js'
import type { AuditReport, ScoredFactor, SitemapAuditReport } from '../types.js'

function gradeColor(grade: string): string {
if (grade.startsWith('A')) return GREEN
Expand Down Expand Up @@ -72,3 +72,62 @@ export function formatText(report: AuditReport): string {

return lines.join('\n')
}

export function formatSitemapText(report: SitemapAuditReport, topIssuesOnly = false): string {
const lines = []

const gc = gradeColor(report.aggregateGrade)
lines.push(``)
lines.push(`${BOLD}AEO Sitemap Audit Report${RESET}`)
lines.push(`${DIM}${report.sitemapUrl}${RESET}`)
lines.push(``)
lines.push(` ${BOLD}Aggregate Grade:${RESET} ${gc}${BOLD}${report.aggregateGrade}${RESET} ${bar(report.aggregateScore, 30)} ${report.aggregateScore}/100`)
lines.push(` ${DIM}${report.pagesAudited} pages audited, ${report.pagesSkipped} skipped, ${report.pagesDiscovered} discovered${RESET}`)
lines.push(``)

if (!topIssuesOnly) {
lines.push(`${BOLD}Per-Page Scores${RESET}`)
lines.push(`${'─'.repeat(70)}`)

const sorted = [...report.pages].sort((a, b) => b.overallScore - a.overallScore)
for (const page of sorted) {
if (page.status === 'error') {
const url = page.url.length > 50 ? page.url.slice(0, 47) + '...' : page.url
lines.push(` ${RED}✗${RESET} ${url.padEnd(50)} ${RED}error${RESET}`)
} else {
const url = page.url.length > 50 ? page.url.slice(0, 47) + '...' : page.url
const pgc = gradeColor(page.overallGrade)
lines.push(` ${statusIcon(page.overallScore >= 70 ? 'pass' : page.overallScore >= 40 ? 'partial' : 'fail')} ${url.padEnd(50)} ${bar(page.overallScore, 15)} ${pgc}${page.overallGrade.padEnd(3)}${RESET}`)
}
}

lines.push(`${'─'.repeat(70)}`)
lines.push(``)
}

if (report.crossCuttingIssues.length > 0) {
lines.push(`${BOLD}Cross-Cutting Issues${RESET}`)
lines.push(`${'─'.repeat(70)}`)

for (const issue of report.crossCuttingIssues) {
const pct = Math.round((issue.affectedPages / issue.totalPages) * 100)
const igc = gradeColor(issue.avgGrade)
lines.push(` ${igc}${issue.avgGrade.padEnd(3)}${RESET} ${issue.factorName.padEnd(32)} ${DIM}avg ${issue.avgScore}/100, affects ${pct}% of pages${RESET}`)
}

lines.push(`${'─'.repeat(70)}`)
lines.push(``)
}

if (report.prioritizedFixes.length > 0) {
lines.push(`${BOLD}Prioritized Fixes (by site-wide impact)${RESET}`)
for (let i = 0; i < report.prioritizedFixes.length; i++) {
lines.push(` ${CYAN}${i + 1}.${RESET} ${report.prioritizedFixes[i]}`)
}
lines.push(``)
}

lines.push(`${DIM}${report.auditedAt}${RESET}`)

return lines.join('\n')
}
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import { getVisibleText, parseJsonLdScripts, countWords } from './analyzers/help
import { FACTOR_DEFINITIONS, OPTIONAL_FACTOR_DEFINITIONS, scoreFactors } from './scoring.js'
import type { Analyzer, AuditContext, AuditReport, RunAeoAuditOptions, ScoredFactor } from './types.js'

export { runSitemapAudit } from './sitemap.js'
export type { SitemapAuditReport, SitemapAuditOptions } from './types.js'

const ANALYZER_BY_ID: Record<string, Analyzer> = {
'structured-data': analyzeStructuredData,
'ai-readable-content': analyzeAiReadableContent,
Expand Down
Loading
Loading