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.3.4",
"version": "1.4.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
18 changes: 12 additions & 6 deletions src/analyzers/technical-seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,20 @@ export function analyzeTechnicalSeo(context: AuditContext): AnalysisResult {

if (!metaDesc) {
findings.push({ type: 'missing', message: 'No meta description found.' })
recommendations.push('Add a meta description (120–160 characters) summarising the page for search snippets and AI crawlers.')
} else if (metaDesc.length < 70) {
score += 10
findings.push({ type: 'info', message: `Meta description is short (${metaDesc.length} chars): "${metaDesc}"` })
recommendations.push('Expand the meta description to 120–160 characters for better search snippet coverage.')
recommendations.push('Add a meta description (150–160 characters) summarising the page. Short or missing descriptions reduce click-through rates and give AI crawlers less context about the page.')
} else if (metaDesc.length < 120) {
score += 8
findings.push({
type: 'info',
message: `Meta description is too short (${metaDesc.length} chars; target 150–160): "${metaDesc}"`,
})
recommendations.push(
'Expand the meta description to 150–160 characters. Short descriptions don\'t give search engines and AI crawlers enough context about the page, which can lower click-through rates and reduce visibility.',
)
} else if (metaDesc.length > 160) {
score += 15
score += 12
findings.push({ type: 'info', message: `Meta description is long (${metaDesc.length} chars) and may be truncated in search results.` })
recommendations.push('Trim the meta description to 150–160 characters so it isn\'t truncated in search snippets.')
} else {
score += 20
findings.push({ type: 'found', message: `Meta description present (${metaDesc.length} chars).` })
Expand Down
18 changes: 18 additions & 0 deletions src/formatters/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,24 @@ export function formatSitemapMarkdown(report: SitemapAuditReport, topIssuesOnly
}

lines.push(``)

const factorsWithIssues = report.crossCuttingIssues.filter((i) => i.topIssues.length > 0)
if (factorsWithIssues.length > 0) {
lines.push(`### Per-Issue Breakdown`)
lines.push(``)

for (const issue of factorsWithIssues) {
lines.push(`**${issue.factorName}**`)
lines.push(``)
for (const detail of issue.topIssues) {
lines.push(`- ${detail.recommendation} _(${detail.affectedUrls.length}/${issue.totalPages} pages)_`)
for (const url of detail.affectedUrls) {
lines.push(` - ${url}`)
}
}
lines.push(``)
}
}
}

if (report.prioritizedFixes.length > 0) {
Expand Down
7 changes: 7 additions & 0 deletions src/formatters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ export function formatSitemapText(report: SitemapAuditReport, topIssuesOnly = fa
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}`)

for (const detail of issue.topIssues) {
lines.push(` ${DIM}• ${detail.recommendation}${RESET} ${DIM}(${detail.affectedUrls.length}/${issue.totalPages} pages)${RESET}`)
for (const url of detail.affectedUrls) {
lines.push(` ${DIM}- ${url}${RESET}`)
}
}
}

lines.push(`${'─'.repeat(70)}`)
Expand Down
30 changes: 19 additions & 11 deletions src/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,11 @@ async function resolveSitemapUrls(sitemapUrl: string): Promise<SitemapEntry[]> {
function buildCrossCuttingIssues(successPages: AuditReport[]): CrossCuttingIssue[] {
if (successPages.length === 0) return []

// Collect scores per factor across all pages
const factorScores = new Map<string, { name: string; scores: number[]; recommendations: Map<string, number> }>()
// Collect scores per factor across all pages. For each recommendation, track the URLs that produced it.
const factorScores = new Map<
string,
{ name: string; scores: number[]; recommendations: Map<string, string[]> }
>()

for (const page of successPages) {
for (const factor of page.factors) {
Expand All @@ -147,7 +150,12 @@ function buildCrossCuttingIssues(successPages: AuditReport[]): CrossCuttingIssue
entry.scores.push(factor.score)

for (const rec of factor.recommendations) {
entry.recommendations.set(rec, (entry.recommendations.get(rec) || 0) + 1)
const urls = entry.recommendations.get(rec)
if (urls) {
urls.push(page.finalUrl)
} else {
entry.recommendations.set(rec, [page.finalUrl])
}
}
}
}
Expand All @@ -158,13 +166,12 @@ function buildCrossCuttingIssues(successPages: AuditReport[]): CrossCuttingIssue
const avgScore = Math.round(entry.scores.reduce((a, b) => a + b, 0) / entry.scores.length)
const affectedPages = entry.scores.filter((s) => s < 70).length

if (affectedPages === 0) continue
if (affectedPages === 0 && entry.recommendations.size === 0) continue

// Sort recommendations by frequency
const topRecs = [...entry.recommendations.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([rec]) => rec)
// Sort recommendations by how many URLs they affect (desc), then alphabetically for stability
const sortedIssues = [...entry.recommendations.entries()]
.sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]))
.map(([recommendation, affectedUrls]) => ({ recommendation, affectedUrls }))

issues.push({
factorId,
Expand All @@ -173,7 +180,8 @@ function buildCrossCuttingIssues(successPages: AuditReport[]): CrossCuttingIssue
avgGrade: scoreToGrade(avgScore),
affectedPages,
totalPages: successPages.length,
topRecommendations: topRecs,
topRecommendations: sortedIssues.slice(0, 3).map((i) => i.recommendation),
topIssues: sortedIssues,
})
}

Expand Down Expand Up @@ -276,4 +284,4 @@ export async function runSitemapAudit(rawUrl: string, options: SitemapAuditOptio
}
}

export { parseSitemapXml, shouldSkipUrl }
export { parseSitemapXml, shouldSkipUrl, buildCrossCuttingIssues }
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,11 @@ export interface SitemapPageResult {
metadata?: AuditMetadata
}

export interface CrossCuttingIssueDetail {
recommendation: string
affectedUrls: string[]
}

export interface CrossCuttingIssue {
factorId: string
factorName: string
Expand All @@ -160,6 +165,7 @@ export interface CrossCuttingIssue {
affectedPages: number
totalPages: number
topRecommendations: string[]
topIssues: CrossCuttingIssueDetail[]
}

export interface SitemapAuditReport {
Expand Down
71 changes: 71 additions & 0 deletions test/analyzers/technical-seo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest'
import { load } from 'cheerio'

import { analyzeTechnicalSeo } from '../../src/analyzers/technical-seo.js'
import { getVisibleText, parseJsonLdScripts } from '../../src/analyzers/helpers.js'
import type { AuditContext, AuxiliaryResources } from '../../src/types.js'

function aux(): AuxiliaryResources {
return {
llmsTxt: { state: 'missing', body: '' },
llmsFullTxt: { state: 'missing', body: '' },
robotsTxt: { state: 'missing', body: '' },
sitemapXml: { state: 'missing', body: '' },
}
}

function buildContext(html: string): AuditContext {
const $ = load(html)
return {
$,
html,
url: 'https://example.com/',
headers: {},
auxiliary: aux(),
structuredData: parseJsonLdScripts($),
textContent: getVisibleText($, html),
pageTitle: $('title').first().text().trim(),
}
}

function pageWithMetaDesc(desc: string): string {
return `<!doctype html><html><head><title>T</title><meta name="description" content="${desc}"><link rel="canonical" href="https://example.com/"></head><body><h1>Topic</h1></body></html>`
}

describe('meta description scoring', () => {
it('flags a missing meta description and recommends 150–160 characters', () => {
const html = `<!doctype html><html><head><title>T</title><link rel="canonical" href="https://example.com/"></head><body><h1>Topic</h1></body></html>`
const result = analyzeTechnicalSeo(buildContext(html))

expect(result.findings.some((f) => f.type === 'missing' && f.message.includes('No meta description'))).toBe(true)
expect(result.recommendations.some((r) => r.includes('150–160 characters'))).toBe(true)
})

it('flags a meta description under 120 chars as too short', () => {
const desc = 'A'.repeat(100)
const result = analyzeTechnicalSeo(buildContext(pageWithMetaDesc(desc)))

const tooShort = result.findings.find((f) => f.message.includes('too short'))
expect(tooShort).toBeDefined()
expect(tooShort?.message).toContain('100 chars')
expect(result.recommendations.some((r) => r.includes('Expand the meta description to 150–160 characters'))).toBe(true)
})

it('awards full meta-description credit in the 120–160 sweet spot', () => {
const short = 'A'.repeat(119)
const good = 'A'.repeat(150)

const shortScore = analyzeTechnicalSeo(buildContext(pageWithMetaDesc(short))).score
const goodScore = analyzeTechnicalSeo(buildContext(pageWithMetaDesc(good))).score

expect(goodScore - shortScore).toBe(12)
})

it('flags a meta description over 160 chars as too long', () => {
const desc = 'A'.repeat(200)
const result = analyzeTechnicalSeo(buildContext(pageWithMetaDesc(desc)))

expect(result.findings.some((f) => f.message.includes('long (200 chars)'))).toBe(true)
expect(result.recommendations.some((r) => r.includes('Trim the meta description'))).toBe(true)
})
})
93 changes: 93 additions & 0 deletions test/sitemap-cross-cutting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest'

import { buildCrossCuttingIssues } from '../src/sitemap.js'
import type { AuditReport, ScoredFactor } from '../src/types.js'

function factor(overrides: Partial<ScoredFactor> & { id: string; name: string }): ScoredFactor {
return {
id: overrides.id,
name: overrides.name,
weight: 5,
grade: overrides.grade ?? 'C',
status: overrides.status ?? 'partial',
score: overrides.score ?? 60,
findings: overrides.findings ?? [],
recommendations: overrides.recommendations ?? [],
}
}

function report(url: string, factors: ScoredFactor[]): AuditReport {
return {
url,
finalUrl: url,
auditedAt: '2026-04-18T00:00:00.000Z',
overallScore: 60,
overallGrade: 'C',
summary: '',
factors,
metadata: {
fetchTimeMs: 0,
pageTitle: '',
wordCount: 0,
auxiliary: { llmsTxt: 'missing', llmsFullTxt: 'missing', robotsTxt: 'missing', sitemapXml: 'missing' },
redirectChain: [],
},
}
}

describe('buildCrossCuttingIssues', () => {
it('aggregates affected URLs per recommendation across pages', () => {
const metaShortRec = 'Expand the meta description to 150–160 characters.'
const canonicalRec = 'Add <link rel="canonical" ...>'

const pages: AuditReport[] = [
report('https://example.com/a', [
factor({ id: 'technical-seo', name: 'Technical SEO', score: 50, recommendations: [metaShortRec, canonicalRec] }),
]),
report('https://example.com/b', [
factor({ id: 'technical-seo', name: 'Technical SEO', score: 55, recommendations: [metaShortRec] }),
]),
report('https://example.com/c', [
factor({ id: 'technical-seo', name: 'Technical SEO', score: 90, recommendations: [] }),
]),
]

const issues = buildCrossCuttingIssues(pages)
expect(issues).toHaveLength(1)

const issue = issues[0]
expect(issue.factorId).toBe('technical-seo')
expect(issue.topIssues).toHaveLength(2)

const metaIssue = issue.topIssues.find((i) => i.recommendation === metaShortRec)
expect(metaIssue).toBeDefined()
expect(metaIssue?.affectedUrls).toEqual(['https://example.com/a', 'https://example.com/b'])

const canonicalIssue = issue.topIssues.find((i) => i.recommendation === canonicalRec)
expect(canonicalIssue?.affectedUrls).toEqual(['https://example.com/a'])
})

it('surfaces issues even when all page scores are above 70 for that factor', () => {
const rec = 'Expand the meta description to 150–160 characters.'
const pages: AuditReport[] = [
report('https://example.com/a', [
factor({ id: 'technical-seo', name: 'Technical SEO', score: 85, recommendations: [rec] }),
]),
]

const issues = buildCrossCuttingIssues(pages)
expect(issues).toHaveLength(1)
expect(issues[0].topIssues[0].recommendation).toBe(rec)
expect(issues[0].topIssues[0].affectedUrls).toEqual(['https://example.com/a'])
})

it('omits factors with no recommendations and no low-scoring pages', () => {
const pages: AuditReport[] = [
report('https://example.com/a', [
factor({ id: 'citations', name: 'Citations', score: 95, recommendations: [] }),
]),
]

expect(buildCrossCuttingIssues(pages)).toHaveLength(0)
})
})
Loading