diff --git a/package.json b/package.json index f208838..f2e2e01 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/analyzers/technical-seo.ts b/src/analyzers/technical-seo.ts index f570452..dd17256 100644 --- a/src/analyzers/technical-seo.ts +++ b/src/analyzers/technical-seo.ts @@ -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).` }) diff --git a/src/formatters/markdown.ts b/src/formatters/markdown.ts index 0a8be25..16cf70d 100644 --- a/src/formatters/markdown.ts +++ b/src/formatters/markdown.ts @@ -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) { diff --git a/src/formatters/text.ts b/src/formatters/text.ts index 3cd5885..89d7349 100644 --- a/src/formatters/text.ts +++ b/src/formatters/text.ts @@ -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)}`) diff --git a/src/sitemap.ts b/src/sitemap.ts index e42b099..01f990b 100644 --- a/src/sitemap.ts +++ b/src/sitemap.ts @@ -134,8 +134,11 @@ async function resolveSitemapUrls(sitemapUrl: string): Promise { function buildCrossCuttingIssues(successPages: AuditReport[]): CrossCuttingIssue[] { if (successPages.length === 0) return [] - // Collect scores per factor across all pages - const factorScores = new Map }>() + // 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 } + >() for (const page of successPages) { for (const factor of page.factors) { @@ -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]) + } } } } @@ -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, @@ -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, }) } @@ -276,4 +284,4 @@ export async function runSitemapAudit(rawUrl: string, options: SitemapAuditOptio } } -export { parseSitemapXml, shouldSkipUrl } +export { parseSitemapXml, shouldSkipUrl, buildCrossCuttingIssues } diff --git a/src/types.ts b/src/types.ts index 928accd..c8cc82f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -152,6 +152,11 @@ export interface SitemapPageResult { metadata?: AuditMetadata } +export interface CrossCuttingIssueDetail { + recommendation: string + affectedUrls: string[] +} + export interface CrossCuttingIssue { factorId: string factorName: string @@ -160,6 +165,7 @@ export interface CrossCuttingIssue { affectedPages: number totalPages: number topRecommendations: string[] + topIssues: CrossCuttingIssueDetail[] } export interface SitemapAuditReport { diff --git a/test/analyzers/technical-seo.test.ts b/test/analyzers/technical-seo.test.ts new file mode 100644 index 0000000..b98574b --- /dev/null +++ b/test/analyzers/technical-seo.test.ts @@ -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 `T

Topic

` +} + +describe('meta description scoring', () => { + it('flags a missing meta description and recommends 150–160 characters', () => { + const html = `T

Topic

` + 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) + }) +}) diff --git a/test/sitemap-cross-cutting.test.ts b/test/sitemap-cross-cutting.test.ts new file mode 100644 index 0000000..40cc4ce --- /dev/null +++ b/test/sitemap-cross-cutting.test.ts @@ -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 & { 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 ' + + 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) + }) +})