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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 4.0.0 (2026-06-09)

### Breaking
- **Renamed the `ai-readable-content` factor to `ai-access-files` ("AI Access Files (llms.txt, sitemap)").** The factor that scores root-level AI access files (`/llms.txt`, `/llms-full.txt`, `/robots.txt`, `/sitemap.xml`, and per-page Markdown source endpoints) now uses the id `ai-access-files` and the display name **AI Access Files (llms.txt, sitemap)**. Breaking for anything keyed on the old identifier:
- `--factors ai-readable-content` is now **`--factors ai-access-files`**.
- All 20 finding codes are renamed from `ai-readable-content.*` to `ai-access-files.*` (e.g. `ai-readable-content.llms-txt.strong` → `ai-access-files.llms-txt.strong`). Full registry in [docs/finding-codes.md](docs/finding-codes.md).
- The analyzer export is renamed `analyzeAiReadableContent` → **`analyzeAiAccessFiles`** (subpath `@ainyc/aeo-audit/analyzers/ai-readable-content` → `…/ai-access-files`), and the `FACTOR_SPEC_RULES` key changes to match.
- Scores and the 5% weight are unchanged, and the report JSON **shape** is identical — but `schemaVersion` is bumped **`2.1` → `3.0`** to flag the breaking identifier rename, so agent parsers pinned to the old factor id or finding codes detect the drift via the major bump (per the documented "treat a major bump as breaking" contract).

## 3.1.0 (2026-06-09)

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const report = await runSitemapAudit('https://example.com', {
factors: ['schema-validity', 'structured-data'], // Optional subset
})

console.log(report.schemaVersion) // '2.1', JSON shape version (see "Machine-readable output")
console.log(report.schemaVersion) // '3.0', JSON shape version (see "Machine-readable output")
console.log(report.aggregateScore) // 84
console.log(report.pagesAudited) // 22
console.log(report.criticalDefects) // Binary per-page defects (multiple/missing H1, missing title/meta), grouped by defect
Expand Down
2 changes: 1 addition & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ The relaxation is **scoped to the single host you named on the CLI, and only tha

## Auxiliary file diagnostics

When fetching `/llms.txt`, `/llms-full.txt`, `/robots.txt`, and `/sitemap.xml` the audit runs a **content-negotiation probe** that surfaces as a finding on the **AI-Readable Content** factor: if a file returns OK to a bare request but a non-2xx response under `Accept: text/markdown`, the audit reports a content-negotiation trap. This catches Astro / Vercel / Starlight setups that redirect `.txt` → non-existent `.md` for markdown-accepting clients, which makes the file invisible to AI content-extraction tools, even though the file is "present" by every other measure.
When fetching `/llms.txt`, `/llms-full.txt`, `/robots.txt`, and `/sitemap.xml` the audit runs a **content-negotiation probe** that surfaces as a finding on the **AI Access Files (llms.txt, sitemap)** factor: if a file returns OK to a bare request but a non-2xx response under `Accept: text/markdown`, the audit reports a content-negotiation trap. This catches Astro / Vercel / Starlight setups that redirect `.txt` → non-existent `.md` for markdown-accepting clients, which makes the file invisible to AI content-extraction tools, even though the file is "present" by every other measure.

## Flag reference

Expand Down
44 changes: 22 additions & 22 deletions docs/finding-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,28 +35,28 @@ Every `AuditFinding` carries a stable `code` so integrations can key on a machin
- `content-depth.lists.present`
- `content-depth.lists.none`

### AI-Readable Content

- `ai-readable-content.content-negotiation.found`
- `ai-readable-content.aux-resource.missing`
- `ai-readable-content.aux-resource.timeout`
- `ai-readable-content.aux-resource.unreachable`
- `ai-readable-content.aux-resource.not-html`
- `ai-readable-content.aux-resource.found`
- `ai-readable-content.llms-txt.strong`
- `ai-readable-content.llms-txt.short`
- `ai-readable-content.llms-full-txt.strong`
- `ai-readable-content.llms-full-txt.short`
- `ai-readable-content.robots-txt.found`
- `ai-readable-content.robots-txt.unreachable`
- `ai-readable-content.robots-txt.missing`
- `ai-readable-content.sitemap.found`
- `ai-readable-content.sitemap.unreachable`
- `ai-readable-content.sitemap.missing`
- `ai-readable-content.llms-txt-link.found`
- `ai-readable-content.llms-txt-link.missing`
- `ai-readable-content.markdown-endpoint.found`
- `ai-readable-content.markdown-endpoint.missing`
### AI Access Files (llms.txt, sitemap)

- `ai-access-files.content-negotiation.found`
- `ai-access-files.aux-resource.missing`
- `ai-access-files.aux-resource.timeout`
- `ai-access-files.aux-resource.unreachable`
- `ai-access-files.aux-resource.not-html`
- `ai-access-files.aux-resource.found`
- `ai-access-files.llms-txt.strong`
- `ai-access-files.llms-txt.short`
- `ai-access-files.llms-full-txt.strong`
- `ai-access-files.llms-full-txt.short`
- `ai-access-files.robots-txt.found`
- `ai-access-files.robots-txt.unreachable`
- `ai-access-files.robots-txt.missing`
- `ai-access-files.sitemap.found`
- `ai-access-files.sitemap.unreachable`
- `ai-access-files.sitemap.missing`
- `ai-access-files.llms-txt-link.found`
- `ai-access-files.llms-txt-link.missing`
- `ai-access-files.markdown-endpoint.found`
- `ai-access-files.markdown-endpoint.missing`

### E-E-A-T Signals

Expand Down
2 changes: 1 addition & 1 deletion docs/scoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ AI answer engines are replacing traditional search for millions of queries. Gett
|--------|--------|---------------|
| Structured Data (JSON-LD) | 12% | Presence of LocalBusiness, FAQPage, Service, HowTo schemas |
| Content Depth | 10% | Word count, heading hierarchy, paragraph structure, lists |
| AI-Readable Content | 5% | llms.txt, llms-full.txt, robots.txt, sitemap.xml availability, per-page Markdown source endpoints |
| AI Access Files (llms.txt, sitemap) | 5% | llms.txt, llms-full.txt, robots.txt, sitemap.xml availability, per-page Markdown source endpoints |
| E-E-A-T Signals | 8% | Author meta, Person schema credentials, trust pages, reviews |
| FAQ Content | 8% | FAQPage schema, details/summary blocks, question-style headings |
| Citations & Authority | 8% | External links, authoritative domains, sameAs references |
Expand Down
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": "3.1.0",
"version": "4.0.0",
"description": "The most comprehensive open-source Answer Engine Optimization (AEO) audit tool. Scores websites across 16 ranking factors that determine AI citation.",
"type": "module",
"main": "./dist/index.js",
Expand Down
4 changes: 2 additions & 2 deletions skills/aeo/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ Use `--format json` for the full report, or **`--format agent`** for just the de

#### Auxiliary File Diagnostics

When the audit fetches `/llms.txt`, `/llms-full.txt`, `/robots.txt`, and `/sitemap.xml`, it probes once with `Accept: text/markdown` to detect a **content-negotiation** trap: file responds OK to a bare request but returns a non-2xx response when the client prefers markdown. This catches Astro / Vercel / Starlight setups that 307-redirect `.txt` → non-existent `.md` for markdown-accepting clients, making the file invisible to AI content-extraction tools even though the file exists. The diagnostic surfaces as a finding on the **AI-Readable Content** factor.
When the audit fetches `/llms.txt`, `/llms-full.txt`, `/robots.txt`, and `/sitemap.xml`, it probes once with `Accept: text/markdown` to detect a **content-negotiation** trap: file responds OK to a bare request but returns a non-2xx response when the client prefers markdown. This catches Astro / Vercel / Starlight setups that 307-redirect `.txt` → non-existent `.md` for markdown-accepting clients, making the file invisible to AI content-extraction tools even though the file exists. The diagnostic surfaces as a finding on the **AI Access Files (llms.txt, sitemap)** factor.

### Local Dev / Staging Targets

Expand Down Expand Up @@ -280,7 +280,7 @@ Use when the user wants `llms.txt` or `llms-full.txt` created or improved.
If a URL is provided:
1. Run:
```bash
npx @ainyc/aeo-audit@1 "<url>" [flags] --format json --factors ai-readable-content
npx @ainyc/aeo-audit@1 "<url>" [flags] --format json --factors ai-access-files
```
2. Inspect existing AI-readable files if present.
3. Extract key content from the site.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ function pushDiagnosticFindings(
if (diagnostics.contentNegotiation) {
findings.push({
type: 'info',
code: 'ai-readable-content.content-negotiation.found',
code: 'ai-access-files.content-negotiation.found',
message: `${label} returns a non-2xx response when fetched with \`Accept: text/markdown\` — content negotiation hides it from AI content extraction tools that prefer markdown.`,
})
recommendations.push(
Expand All @@ -43,31 +43,31 @@ function scoreAuxState(
recommendations: string[],
): number {
if (!auxEntry || auxEntry.state === 'missing') {
findings.push({ type: 'missing', code: 'ai-readable-content.aux-resource.missing', message: missingMessage })
findings.push({ type: 'missing', code: 'ai-access-files.aux-resource.missing', message: missingMessage })
recommendations.push(`Create ${missingMessage.split(' ')[0]} at your site root.`)
return 0
}

if (auxEntry.state === 'timeout') {
findings.push({ type: 'timeout', code: 'ai-readable-content.aux-resource.timeout', message: unavailableMessage })
findings.push({ type: 'timeout', code: 'ai-access-files.aux-resource.timeout', message: unavailableMessage })
return 8
}

if (auxEntry.state === 'unreachable') {
findings.push({ type: 'unreachable', code: 'ai-readable-content.aux-resource.unreachable', message: unavailableMessage })
findings.push({ type: 'unreachable', code: 'ai-access-files.aux-resource.unreachable', message: unavailableMessage })
return 8
}

if (auxEntry.state === 'not-html') {
findings.push({ type: 'info', code: 'ai-readable-content.aux-resource.not-html', message: `${missingMessage.split(' ')[0]} returned an unexpected content type.` })
findings.push({ type: 'info', code: 'ai-access-files.aux-resource.not-html', message: `${missingMessage.split(' ')[0]} returned an unexpected content type.` })
return 10
}

findings.push({ type: 'found', code: 'ai-readable-content.aux-resource.found', message: `${missingMessage.split(' ')[0]} is available.` })
findings.push({ type: 'found', code: 'ai-access-files.aux-resource.found', message: `${missingMessage.split(' ')[0]} is available.` })
return 24
}

export function analyzeAiReadableContent(context: AuditContext): AnalysisResult {
export function analyzeAiAccessFiles(context: AuditContext): AnalysisResult {
const findings: AnalysisResult['findings'] = []
const recommendations: string[] = []
const auxiliary = context.auxiliary || {}
Expand All @@ -87,9 +87,9 @@ export function analyzeAiReadableContent(context: AuditContext): AnalysisResult
const wordCount = countWords(auxiliary.llmsTxt.body || '')
if (wordCount >= 100) {
score += 8
findings.push({ type: 'found', code: 'ai-readable-content.llms-txt.strong', message: '/llms.txt has useful content depth.' })
findings.push({ type: 'found', code: 'ai-access-files.llms-txt.strong', message: '/llms.txt has useful content depth.' })
} else {
findings.push({ type: 'info', code: 'ai-readable-content.llms-txt.short', message: '/llms.txt is present but short.' })
findings.push({ type: 'info', code: 'ai-access-files.llms-txt.short', message: '/llms.txt is present but short.' })
recommendations.push('Expand /llms.txt with concise service and entity context.')
}
}
Expand All @@ -108,9 +108,9 @@ export function analyzeAiReadableContent(context: AuditContext): AnalysisResult
const wordCount = countWords(auxiliary.llmsFullTxt.body || '')
if (wordCount >= 200) {
score += 10
findings.push({ type: 'found', code: 'ai-readable-content.llms-full-txt.strong', message: '/llms-full.txt has strong long-form coverage.' })
findings.push({ type: 'found', code: 'ai-access-files.llms-full-txt.strong', message: '/llms-full.txt has strong long-form coverage.' })
} else {
findings.push({ type: 'info', code: 'ai-readable-content.llms-full-txt.short', message: '/llms-full.txt exists but lacks detail.' })
findings.push({ type: 'info', code: 'ai-access-files.llms-full-txt.short', message: '/llms-full.txt exists but lacks detail.' })
recommendations.push('Add complete offerings, FAQ, and service-area coverage to /llms-full.txt.')
}
}
Expand All @@ -119,12 +119,12 @@ export function analyzeAiReadableContent(context: AuditContext): AnalysisResult
const robotsState = auxiliary.robotsTxt?.state
if (robotsState === 'ok') {
score += 16
findings.push({ type: 'found', code: 'ai-readable-content.robots-txt.found', message: 'robots.txt is accessible.' })
findings.push({ type: 'found', code: 'ai-access-files.robots-txt.found', message: 'robots.txt is accessible.' })
} else if (robotsState === 'timeout' || robotsState === 'unreachable') {
score += 6
findings.push({ type: robotsState, code: 'ai-readable-content.robots-txt.unreachable', message: 'Could not reliably fetch /robots.txt.' })
findings.push({ type: robotsState, code: 'ai-access-files.robots-txt.unreachable', message: 'Could not reliably fetch /robots.txt.' })
} else {
findings.push({ type: 'missing', code: 'ai-readable-content.robots-txt.missing', message: '/robots.txt is missing.' })
findings.push({ type: 'missing', code: 'ai-access-files.robots-txt.missing', message: '/robots.txt is missing.' })
recommendations.push('Add a robots.txt file.')
}
pushDiagnosticFindings('/robots.txt', auxiliary.robotsTxt, findings, recommendations)
Expand All @@ -133,12 +133,12 @@ export function analyzeAiReadableContent(context: AuditContext): AnalysisResult
const sitemapState = auxiliary.sitemapXml?.state
if (sitemapState === 'ok') {
score += 16
findings.push({ type: 'found', code: 'ai-readable-content.sitemap.found', message: 'sitemap.xml is accessible.' })
findings.push({ type: 'found', code: 'ai-access-files.sitemap.found', message: 'sitemap.xml is accessible.' })
} else if (sitemapState === 'timeout' || sitemapState === 'unreachable') {
score += 6
findings.push({ type: sitemapState, code: 'ai-readable-content.sitemap.unreachable', message: 'Could not reliably fetch /sitemap.xml.' })
findings.push({ type: sitemapState, code: 'ai-access-files.sitemap.unreachable', message: 'Could not reliably fetch /sitemap.xml.' })
} else {
findings.push({ type: 'missing', code: 'ai-readable-content.sitemap.missing', message: '/sitemap.xml is missing.' })
findings.push({ type: 'missing', code: 'ai-access-files.sitemap.missing', message: '/sitemap.xml is missing.' })
recommendations.push('Add a sitemap.xml file.')
}
pushDiagnosticFindings('/sitemap.xml', auxiliary.sitemapXml, findings, recommendations)
Expand All @@ -147,9 +147,9 @@ export function analyzeAiReadableContent(context: AuditContext): AnalysisResult
const llmsLink = context.$('link[href*="llms.txt"]').length > 0
if (llmsLink) {
score += 10
findings.push({ type: 'found', code: 'ai-readable-content.llms-txt-link.found', message: 'HTML head links to llms.txt.' })
findings.push({ type: 'found', code: 'ai-access-files.llms-txt-link.found', message: 'HTML head links to llms.txt.' })
} else {
findings.push({ type: 'info', code: 'ai-readable-content.llms-txt-link.missing', message: 'No llms.txt link detected in <head>.' })
findings.push({ type: 'info', code: 'ai-access-files.llms-txt-link.missing', message: 'No llms.txt link detected in <head>.' })
recommendations.push('Add a <link> reference to /llms.txt in your document head.')
}

Expand All @@ -161,9 +161,9 @@ export function analyzeAiReadableContent(context: AuditContext): AnalysisResult
const markdownLinkHeader = /type="?text\/markdown"?/i.test(linkHeader)
if (markdownLinkTag || markdownLinkHeader) {
score += 10
findings.push({ type: 'found', code: 'ai-readable-content.markdown-endpoint.found', message: 'Per-page Markdown source endpoint advertised (text/markdown alternate) — agents can fetch unrendered source.' })
findings.push({ type: 'found', code: 'ai-access-files.markdown-endpoint.found', message: 'Per-page Markdown source endpoint advertised (text/markdown alternate) — agents can fetch unrendered source.' })
} else {
findings.push({ type: 'info', code: 'ai-readable-content.markdown-endpoint.missing', message: 'No per-page Markdown source endpoint advertised (text/markdown alternate link or Link header).' })
findings.push({ type: 'info', code: 'ai-access-files.markdown-endpoint.missing', message: 'No per-page Markdown source endpoint advertised (text/markdown alternate link or Link header).' })
recommendations.push(
`Expose a Markdown version of each page (a .md URL or content negotiation) and advertise it via <link rel="alternate" type="text/markdown">. ${specCitation('markdown-source-endpoints')}`,
)
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { load } from 'cheerio'
import { fetchPage, normalizeTargetUrl } from './fetch-page.js'
import { AeoAuditError } from './errors.js'
import { analyzeStructuredData } from './analyzers/structured-data.js'
import { analyzeAiReadableContent } from './analyzers/ai-readable-content.js'
import { analyzeAiAccessFiles } from './analyzers/ai-access-files.js'
import { analyzeEntityConsistency } from './analyzers/entity-consistency.js'
import { analyzeContentDepth } from './analyzers/content-depth.js'
import { analyzeDefinitionBlocks } from './analyzers/definition-blocks.js'
Expand Down Expand Up @@ -64,7 +64,7 @@ export type {

const ANALYZER_BY_ID: Record<string, Analyzer> = {
'structured-data': analyzeStructuredData,
'ai-readable-content': analyzeAiReadableContent,
'ai-access-files': analyzeAiAccessFiles,
'entity-consistency': analyzeEntityConsistency,
'content-depth': analyzeContentDepth,
'definition-blocks': analyzeDefinitionBlocks,
Expand Down
2 changes: 1 addition & 1 deletion src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
* Lives in its own module (not `index.ts`) so report builders can read it without
* importing the audit entry points — which test suites routinely mock.
*/
export const SCHEMA_VERSION = '2.1'
export const SCHEMA_VERSION = '3.0'
2 changes: 1 addition & 1 deletion src/scoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { FactorDefinition, RawFactorResult, ScoredFactorSummary } from './t
export const FACTOR_DEFINITIONS: FactorDefinition[] = [
{ id: 'structured-data', name: 'Structured Data (JSON-LD)', weight: 12 },
{ id: 'content-depth', name: 'Content Depth', weight: 10 },
{ id: 'ai-readable-content', name: 'AI-Readable Content', weight: 5 },
{ id: 'ai-access-files', name: 'AI Access Files (llms.txt, sitemap)', weight: 5 },
{ id: 'eeat-signals', name: 'E-E-A-T Signals', weight: 8 },
{ id: 'faq-content', name: 'FAQ Content', weight: 8 },
{ id: 'citations', name: 'Citations & Authority Signals', weight: 8 },
Expand Down
2 changes: 1 addition & 1 deletion src/spec-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export type SpecRuleId = keyof typeof SPEC_RULES
*/
export const FACTOR_SPEC_RULES: Record<string, SpecRuleId[]> = {
'structured-data': ['structured-data-for-agents'],
'ai-readable-content': ['llms-txt', 'llms-full-txt', 'markdown-source-endpoints', 'link-headers'],
'ai-access-files': ['llms-txt', 'llms-full-txt', 'markdown-source-endpoints', 'link-headers'],
'ai-crawler-access': ['robots-for-ai-crawlers', 'content-signals'],
'agent-skill-exposure': ['mcp-and-tool-discovery', 'agent-skills-discovery', 'a2a-agent-cards', 'web-bot-auth'],
}
Expand Down
Loading
Loading