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

## 1.7.0 (2026-04-30)

### Added
- New `schema-validity` analyzer (weight 5) that flags page-level JSON-LD problems missed by existing factors:
- Duplicate singleton `@type`s on a page (e.g., two `FAQPage` blocks — Google flags as "Duplicate field" and invalidates rich results)
- JSON syntax errors in any `<script type="application/ld+json">` block (previously silently swallowed)
- Empty / whitespace-only JSON-LD `<script>` blocks
- `extractJsonLdBlocks()` helper exported from `analyzers/helpers.js` for richer per-block introspection (index, parse error, top-level `@type`s)

### Behavior
- When the validator finds a structural error (duplicate singleton or JSON parse error), the factor's score is capped at `69` so the issue surfaces in text-mode top recommendations regardless of how many other factors are also failing — schema errors must be visible irrespective of the numeric score.

## 1.0.3 (2026-03-06)

### Changed
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Project Overview

@ainyc/aeo-audit — an open-source AEO (Answer Engine Optimization) audit engine and single umbrella Claude Code / ClawHub skill. Scores websites across 13 ranking factors that determine AI citation.
@ainyc/aeo-audit — an open-source AEO (Answer Engine Optimization) audit engine and single umbrella Claude Code / ClawHub skill. Scores websites across 14 ranking factors that determine AI citation.

Website: https://ainyc.ai

Expand Down Expand Up @@ -37,7 +37,7 @@ src/
errors.ts # AeoAuditError class
cli.ts # CLI argument parsing
formatters/ # json, markdown, text output formatters
analyzers/ # 14 analyzer modules (13 core + 1 optional)
analyzers/ # Per-factor analyzer modules (15 core + 2 optional) plus shared helpers.ts
types.ts # Shared audit/report TypeScript types
dist/ # Compiled publishable ESM output
bin/
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @ainyc/aeo-audit

The most comprehensive open-source Answer Engine Optimization (AEO) audit tool. Scores any website across 13 ranking factors that determine whether AI answer engines — ChatGPT, Perplexity, Gemini, Claude — will cite your content.
The most comprehensive open-source Answer Engine Optimization (AEO) audit tool. Scores any website across 14 ranking factors that determine whether AI answer engines — ChatGPT, Perplexity, Gemini, Claude — will cite your content.

Website: [ainyc.ai](https://ainyc.ai)

Expand Down Expand Up @@ -34,7 +34,7 @@ AI answer engines are replacing traditional search for millions of queries. Gett
- **E-E-A-T signals** (author credentials, trust pages) determine citation trustworthiness
- **Content extractability** — clean, well-structured content gets cited; paywalled content doesn't

## 13 Scoring Factors
## 14 Scoring Factors

| Factor | Weight | What It Checks |
|--------|--------|---------------|
Expand All @@ -50,6 +50,7 @@ AI answer engines are replacing traditional search for millions of queries. Gett
| Content Extractability | 6% | Content-to-boilerplate ratio, citation-ready blocks, paywall detection |
| Definition Blocks | 6% | "What is", "How to" headings, step lists, HowTo schema, dl elements |
| Named Entities | 6% | Brand mentions, knowsAbout/founder signals, proper noun density |
| Schema Validity | 5% | Duplicate singleton @types, JSON parse errors, empty JSON-LD blocks |
| AI Crawler Access | 4% | Per-bot robots.txt rules for GPTBot, ClaudeBot, PerplexityBot, etc. |

**Optional:** Geographic Signals (7%) — LocalBusiness geo data, address, areaServed. Enable with `--include-geo`.
Expand All @@ -69,6 +70,10 @@ npx @ainyc/aeo-audit https://example.com --format markdown
# Run specific factors only
npx @ainyc/aeo-audit https://example.com --factors structured-data,faq-content

# Validate JSON-LD blocks for parse errors and duplicate singleton @types
# (catches issues like duplicate FAQPage that Google flags as invalid)
npx @ainyc/aeo-audit https://example.com --factors schema-validity

# Include geographic signals
npx @ainyc/aeo-audit https://example.com --include-geo

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@ainyc/aeo-audit",
"version": "1.6.0",
"description": "The most comprehensive open-source Answer Engine Optimization (AEO) audit tool. Scores websites across 13 ranking factors that determine AI citation.",
"version": "1.7.0",
"description": "The most comprehensive open-source Answer Engine Optimization (AEO) audit tool. Scores websites across 14 ranking factors that determine AI citation.",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down
8 changes: 5 additions & 3 deletions skills/aeo/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,20 +166,22 @@ Use when the request is specifically about JSON-LD or schema quality.

1. Run:
```bash
npx @ainyc/aeo-audit@1 "<url>" [flags] --format json --factors structured-data,schema-completeness,entity-consistency
npx @ainyc/aeo-audit@1 "<url>" [flags] --format json --factors structured-data,schema-completeness,schema-validity,entity-consistency
```
2. Report:
- Schema types found
- Property completeness by type
- Missing recommended properties
- **Validity errors** (duplicate singleton `@type`s, JSON parse errors, empty `<script>` blocks) — surface these prominently regardless of overall score; Google drops invalid blocks silently from rich results
- Entity consistency issues
3. Provide corrected JSON-LD examples when useful.

Checklist:
- `LocalBusiness`: name, address, telephone, openingHours, priceRange, image, url, geo, areaServed, sameAs
- `FAQPage`: mainEntity with at least 3 Q&A pairs
- `HowTo`: name and at least 3 steps
- `FAQPage`: mainEntity with at least 3 Q&A pairs (and only **one** `FAQPage` block per page — duplicates invalidate rich results)
- `HowTo`: name and at least 3 steps (singleton — only one per page)
- `Organization`: name, logo, contactPoint, sameAs, foundingDate, url, description
- Singletons that must not repeat per page: `FAQPage`, `HowTo`, `Article`, `BlogPosting`, `NewsArticle`, `BreadcrumbList`, `Product`, `Recipe`

## llms.txt

Expand Down
85 changes: 85 additions & 0 deletions src/analyzers/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,91 @@ export function parseJsonLdScripts($: CheerioAPI): StructuredDataEntry[] {
return items
}

export interface JsonLdBlockInfo {
index: number
isEmpty: boolean
parseError?: string
parsed?: unknown
topLevelTypes: string[]
}

export interface JsonLdExtraction {
totalBlocks: number
blocks: JsonLdBlockInfo[]
}

export function extractJsonLdBlocks($: CheerioAPI): JsonLdExtraction {
const scripts = $('script[type="application/ld+json"]')
const blocks: JsonLdBlockInfo[] = []

scripts.each((index, element) => {
const raw = $(element).html() ?? ''

if (!raw.trim()) {
blocks.push({ index, isEmpty: true, topLevelTypes: [] })
return
}

try {
const parsed: unknown = JSON.parse(raw)
blocks.push({
index,
isEmpty: false,
parsed,
topLevelTypes: collectTopLevelTypes(parsed),
})
} catch (error) {
blocks.push({
index,
isEmpty: false,
parseError: error instanceof Error ? error.message : String(error),
topLevelTypes: [],
})
}
})

return { totalBlocks: blocks.length, blocks }
}

function collectTopLevelTypes(value: unknown): string[] {
const types: string[] = []
walkRoots(value, (record) => {
const rawType = record['@type']
if (typeof rawType === 'string' && rawType.trim()) {
types.push(rawType.trim())
} else if (Array.isArray(rawType)) {
for (const candidate of rawType) {
if (typeof candidate === 'string' && candidate.trim()) {
types.push(candidate.trim())
}
}
}
})
return types
}

function walkRoots(value: unknown, visit: (record: Record<string, unknown>) => void): void {
if (!value) return

if (Array.isArray(value)) {
for (const item of value) {
walkRoots(item, visit)
}
return
}

if (typeof value !== 'object') return

const record = value as Record<string, unknown>
const graph = record['@graph']
if (graph) {
walkRoots(graph, visit)
return
}

visit(record)
}

function flattenStructuredData(candidate: unknown, accumulator: StructuredDataEntry[]): void {
if (!candidate) {
return
Expand Down
100 changes: 100 additions & 0 deletions src/analyzers/schema-validity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { clampScore, extractJsonLdBlocks } from './helpers.js'
import type { AnalysisResult, AuditContext } from '../types.js'

const SINGLETON_TYPES = new Set([
'FAQPage',
'HowTo',
'Article',
'BlogPosting',
'NewsArticle',
'BreadcrumbList',
'Product',
'Recipe',
])

export function analyzeSchemaValidity(context: AuditContext): AnalysisResult {
const findings: AnalysisResult['findings'] = []
const recommendations: string[] = []

const { totalBlocks, blocks } = extractJsonLdBlocks(context.$)

if (totalBlocks === 0) {
findings.push({
type: 'info',
message: 'No JSON-LD blocks found; nothing to validate. Presence of structured data is scored by the structured-data factor.',
})
return { score: 100, findings, recommendations }
}

let score = 100

for (const block of blocks) {
if (block.isEmpty) {
score -= 5
findings.push({
type: 'missing',
message: `JSON-LD block #${block.index + 1} is empty or whitespace-only.`,
})
recommendations.push(`Remove the empty <script type="application/ld+json"> block at position ${block.index + 1}, or populate it with valid JSON-LD.`)
}
}

for (const block of blocks) {
if (block.parseError) {
score -= 15
findings.push({
type: 'missing',
message: `JSON-LD block #${block.index + 1} has invalid JSON syntax: ${block.parseError}`,
})
recommendations.push(`Fix JSON syntax error in block #${block.index + 1} (${block.parseError}). Invalid JSON is silently dropped by Google and AI crawlers.`)
}
}

const typeOccurrences = new Map<string, number[]>()
for (const block of blocks) {
if (block.parseError || block.isEmpty) continue
for (const type of block.topLevelTypes) {
if (!SINGLETON_TYPES.has(type)) continue
const positions = typeOccurrences.get(type) ?? []
positions.push(block.index + 1)
typeOccurrences.set(type, positions)
}
}

let duplicateCount = 0
for (const [type, positions] of typeOccurrences) {
if (positions.length > 1) {
duplicateCount += 1
score -= 25
findings.push({
type: 'missing',
message: `Duplicate singleton @type "${type}" appears ${positions.length} times (blocks #${positions.join(', #')}). Google Search Console flags this as "Duplicate field ${type}" and invalidates rich results.`,
})
recommendations.push(`Remove duplicate "${type}" — keep one canonical block. Duplicate "${type}" entries cause Google to drop both from rich results.`)
}
}

const hasParseError = blocks.some((block) => Boolean(block.parseError))
const hasStructuralError = hasParseError || duplicateCount > 0

// Cap structural-error scores at fail level so the factor surfaces in text-mode
// top-recommendations regardless of how many other factors are also failing.
// Schema parse errors and duplicate singletons are silent but break rich results,
// so they must be visible to the user — flagged irrespective of numeric score.
if (hasStructuralError) {
score = Math.min(score, 69)
}

if (findings.length === 0) {
findings.push({
type: 'found',
message: `All ${totalBlocks} JSON-LD block(s) are valid and unique.`,
})
}

return {
score: clampScore(score),
findings,
recommendations,
}
}
1 change: 1 addition & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ 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 --factors schema-validity
aeo-audit https://example.com --include-geo
aeo-audit https://example.com --include-agent-skills
aeo-audit https://example.com --sitemap
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { analyzeGeographicSignals } from './analyzers/geographic-signals.js'
import { analyzeEeatSignals } from './analyzers/eeat-signals.js'
import { analyzeAiCrawlerAccess } from './analyzers/ai-crawler-access.js'
import { analyzeSchemaCompleteness } from './analyzers/schema-completeness.js'
import { analyzeSchemaValidity } from './analyzers/schema-validity.js'
import { analyzeContentExtractability } from './analyzers/content-extractability.js'
import { analyzeTechnicalSeo } from './analyzers/technical-seo.js'
import { analyzeAgentSkillExposure } from './analyzers/agent-skill-exposure.js'
Expand Down Expand Up @@ -47,6 +48,7 @@ const ANALYZER_BY_ID: Record<string, Analyzer> = {
'eeat-signals': analyzeEeatSignals,
'ai-crawler-access': analyzeAiCrawlerAccess,
'schema-completeness': analyzeSchemaCompleteness,
'schema-validity': analyzeSchemaValidity,
'content-extractability': analyzeContentExtractability,
'technical-seo': analyzeTechnicalSeo,
'agent-skill-exposure': analyzeAgentSkillExposure,
Expand Down
1 change: 1 addition & 0 deletions src/scoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const FACTOR_DEFINITIONS: FactorDefinition[] = [
{ id: 'faq-content', name: 'FAQ Content', weight: 8 },
{ id: 'citations', name: 'Citations & Authority Signals', weight: 8 },
{ id: 'schema-completeness', name: 'Schema Completeness', weight: 8 },
{ id: 'schema-validity', name: 'Schema Validity', weight: 5 },
{ id: 'entity-consistency', name: 'Entity Consistency', weight: 7 },
{ id: 'content-freshness', name: 'Content Freshness', weight: 7 },
{ id: 'content-extractability', name: 'Content Extractability', weight: 6 },
Expand Down
Loading
Loading