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
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
"scripts": {
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc --noEmit",
"test": "tsx --test test/*.test.ts test/analyzers/*.test.ts",
"test": "vitest run",
"test:watch": "vitest",
"pretest:e2e": "pnpm run build",
"test:e2e": "tsx --test test/e2e/*.test.ts",
"lint": "eslint src/ test/ bin/ eslint.config.js",
Expand All @@ -53,7 +54,8 @@
"globals": "^17.4.0",
"tsx": "^4.20.5",
"typescript": "^5.9.2",
"typescript-eslint": "^8.43.0"
"typescript-eslint": "^8.43.0",
"vitest": "^4.1.4"
},
"engines": {
"node": ">=20"
Expand Down
709 changes: 709 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

216 changes: 216 additions & 0 deletions src/analyzers/agent-skill-exposure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { clampScore } from './helpers.js'
import type { AnalysisResult, AuditContext, StructuredDataEntry } from '../types.js'

interface ActionMatch {
type: string
hasTarget: boolean
hasInputShape: boolean
}

function collectActions(structuredData: StructuredDataEntry[]): ActionMatch[] {
const matches: ActionMatch[] = []
const seen = new WeakSet<object>()

function visit(node: unknown): void {
if (!node || typeof node !== 'object') return
if (seen.has(node as object)) return
seen.add(node as object)

if (Array.isArray(node)) {
for (const item of node) visit(item)
return
}

const record = node as Record<string, unknown>
const rawType = record['@type']
const typeValues = Array.isArray(rawType) ? rawType : [rawType]

for (const t of typeValues) {
if (typeof t === 'string' && t.endsWith('Action')) {
matches.push({
type: t,
hasTarget: typeof record.target !== 'undefined',
hasInputShape: typeof record['query-input'] !== 'undefined'
|| typeof record.object !== 'undefined'
|| typeof record.result !== 'undefined',
})
}
}

for (const value of Object.values(record)) {
if (value && typeof value === 'object') visit(value)
}
}

for (const entry of structuredData) visit(entry)
return matches
}

function scoreForm($: AuditContext['$'], form: ReturnType<AuditContext['$']>): number {
const inputs = form.find('input, textarea, select').filter((_, el) => {
const type = $(el).attr('type')
return type !== 'hidden' && type !== 'submit' && type !== 'button'
})

if (inputs.length === 0) return 0

let labeled = 0
let semanticType = 0
let autocomplete = 0
let meaningfulName = 0

inputs.each((_, el) => {
const $el = $(el)
const id = $el.attr('id')
const hasExplicitLabel = Boolean(id && form.find(`label[for="${id}"]`).length)
const hasAria = Boolean($el.attr('aria-label') || $el.attr('aria-labelledby'))
const isWrapped = $el.parents('label').length > 0
if (hasExplicitLabel || hasAria || isWrapped) labeled += 1

const type = ($el.attr('type') || '').toLowerCase()
const tag = (el as { tagName?: string }).tagName?.toLowerCase()
if (tag === 'select' || tag === 'textarea') {
semanticType += 1
} else if (type && type !== 'text') {
semanticType += 1
}

if ($el.attr('autocomplete')) autocomplete += 1

const name = $el.attr('name') || ''
if (name && !/^field[_-]?\d+$/i.test(name) && name.length >= 2) meaningfulName += 1
})

const total = inputs.length
const labelRatio = labeled / total
const typeRatio = semanticType / total
const autoRatio = autocomplete / total
const nameRatio = meaningfulName / total

const formAccessibleName = Boolean(
form.attr('aria-label')
|| form.attr('aria-labelledby')
|| form.attr('name')
|| form.attr('title'),
)
const submit = form.find('button[type="submit"], input[type="submit"], button:not([type])')
const submitText = submit.map((_, el) => $(el).text().trim() || $(el).attr('value') || '').get().join(' ').trim()
const hasSubmitText = submitText.length > 0

const perForm = Math.round(
labelRatio * 35
+ autoRatio * 25
+ typeRatio * 15
+ nameRatio * 10
+ (formAccessibleName ? 10 : 0)
+ (hasSubmitText ? 5 : 0),
)

return perForm
}

export function analyzeAgentSkillExposure(context: AuditContext): AnalysisResult {
const findings: AnalysisResult['findings'] = []
const recommendations: string[] = []
let score = 0

const { $ } = context

// ── Schema.org Action markup (up to 35) ─────────────────────────────────
const actions = collectActions(context.structuredData)
if (actions.length > 0) {
const wellFormed = actions.filter((a) => a.hasTarget && a.hasInputShape)
if (wellFormed.length > 0) {
score += 35
const types = [...new Set(wellFormed.map((a) => a.type))].slice(0, 3).join(', ')
findings.push({ type: 'found', message: `Schema.org Action markup declared with target and inputs: ${types}.` })
} else {
score += 18
const types = [...new Set(actions.map((a) => a.type))].slice(0, 3).join(', ')
findings.push({ type: 'info', message: `Schema.org Action types present (${types}) but missing target/urlTemplate or query-input/object shape.` })
recommendations.push('Add target (with urlTemplate) and query-input/object to Action schema so agents know how to invoke it.')
}
} else {
findings.push({ type: 'missing', message: 'No Schema.org Action markup detected (PotentialAction / SearchAction / OrderAction / etc.).' })
recommendations.push('Declare interactive affordances with Schema.org Action markup (e.g. SearchAction with urlTemplate and query-input) so agents can invoke them as tools.')
}

// ── MCP / WebMCP / ai-plugin discovery (up to 20) ───────────────────────
const mcpLink = $('link[rel~="mcp"], link[rel~="webmcp"], link[rel~="ai-plugin"]').first()
const mcpMeta = $('meta[name="mcp-server"], meta[name="mcp-server-url"], meta[name="ai-plugin"]').first()
const linkHeader = context.headers['link'] || context.headers['Link'] || ''
const headerMcp = /rel="?(mcp|webmcp|ai-plugin)"?/i.test(linkHeader)

if (mcpLink.length || mcpMeta.length || headerMcp) {
score += 20
const src = mcpLink.length
? `<link rel="${mcpLink.attr('rel')}">`
: mcpMeta.length
? `<meta name="${mcpMeta.attr('name')}">`
: 'Link header'
findings.push({ type: 'found', message: `Agent protocol discovery present (${src}).` })
} else {
findings.push({ type: 'missing', message: 'No MCP / WebMCP / ai-plugin discovery link or header.' })
recommendations.push('Expose an MCP server card via <link rel="mcp" href="/.well-known/mcp.json"> or a Link header so agents can discover your tools.')
}

// ── OpenAPI / service-description links (up to 10) ──────────────────────
const openapiLink = $(
'link[rel~="describedby"][type*="openapi"], link[rel~="service-desc"], link[rel~="describedby"][type*="yaml"]',
).first()
if (openapiLink.length) {
score += 10
findings.push({ type: 'found', message: `Service description link found (type="${openapiLink.attr('type') || 'unspecified'}").` })
} else {
findings.push({ type: 'info', message: 'No OpenAPI / service-description link found.' })
recommendations.push('Link to an OpenAPI document via <link rel="describedby" type="application/openapi+json"> so agents can see the underlying endpoint shape.')
}

// ── Microdata typed fields (up to 10) ───────────────────────────────────
const itempropCount = $('[itemprop]').length
const itemtypeCount = $('[itemtype]').length
if (itempropCount >= 3 || itemtypeCount >= 1) {
score += 10
findings.push({ type: 'found', message: `Microdata present (${itempropCount} itemprop, ${itemtypeCount} itemtype) — helps agents map semantic meaning.` })
} else {
findings.push({ type: 'info', message: 'Little or no microdata (itemprop / itemtype) found on the page.' })
}

// ── Form structural fallback (up to 25) ─────────────────────────────────
const forms = $('form')
const candidateForms = forms.filter((_, el) => {
const visibleInputs = $(el).find('input, textarea, select').filter((_, child) => {
const t = $(child).attr('type')
return t !== 'hidden' && t !== 'submit' && t !== 'button'
})
return visibleInputs.length > 0
})

if (candidateForms.length === 0) {
findings.push({ type: 'info', message: 'No interactive forms detected on this page.' })
} else {
const perFormScores: number[] = []
candidateForms.each((_, el) => {
perFormScores.push(scoreForm($, $(el)))
})
const avg = perFormScores.reduce((a, b) => a + b, 0) / perFormScores.length
const formContribution = Math.round((avg / 100) * 25)
score += formContribution

if (avg >= 80) {
findings.push({ type: 'found', message: `${candidateForms.length} form(s) with strong agent-usable structure (labels, autocomplete, semantic types).` })
} else if (avg >= 40) {
findings.push({ type: 'info', message: `${candidateForms.length} form(s) partially agent-usable. Average structure score ${Math.round(avg)}/100.` })
recommendations.push('Strengthen forms with aria-label / <label for>, autocomplete tokens (email, tel, street-address…), and semantic input types (email, tel, number, date).')
} else {
findings.push({ type: 'missing', message: `${candidateForms.length} form(s) have weak structure for agent use (avg ${Math.round(avg)}/100). Inputs lack labels, autocomplete, or semantic types.` })
recommendations.push('Add <label for> or aria-label to every input, set autocomplete tokens, and use semantic input types so agents can identify each field without guessing.')
}
}

return {
score: clampScore(score),
findings,
recommendations,
}
}
7 changes: 7 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface ParsedArgs {
format: string
factors: string[] | null
includeGeo: boolean
includeAgentSkills: boolean
help: boolean
sitemap: boolean
sitemapUrl: string | null
Expand All @@ -44,6 +45,7 @@ function parseArgs(argv: string[]): ParsedArgs {
format: 'text',
factors: null,
includeGeo: false,
includeAgentSkills: false,
help: false,
sitemap: false,
sitemapUrl: null,
Expand All @@ -60,6 +62,8 @@ function parseArgs(argv: string[]): ParsedArgs {
i += 1
} else if (args[i] === '--include-geo') {
result.includeGeo = true
} else if (args[i] === '--include-agent-skills') {
result.includeAgentSkills = true
} else if (args[i] === '--sitemap') {
result.sitemap = true
// Check if the next arg is an explicit sitemap URL (not another flag)
Expand Down Expand Up @@ -93,6 +97,7 @@ 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
--include-agent-skills Include optional agent skill exposure factor (Schema.org Action, MCP, form affordances)
--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
Expand Down Expand Up @@ -133,6 +138,7 @@ export async function main(argv: string[] = process.argv): Promise<number> {
const options: SitemapAuditOptions = {
factors: args.factors,
includeGeo: args.includeGeo,
includeAgentSkills: args.includeAgentSkills,
sitemapUrl: args.sitemapUrl ?? undefined,
limit: args.limit ?? undefined,
topIssuesOnly: args.topIssues,
Expand All @@ -148,6 +154,7 @@ export async function main(argv: string[] = process.argv): Promise<number> {
const report = await runAeoAudit(args.url, {
factors: args.factors,
includeGeo: args.includeGeo,
includeAgentSkills: args.includeAgentSkills,
})

console.log(formatter(report))
Expand Down
17 changes: 12 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { analyzeAiCrawlerAccess } from './analyzers/ai-crawler-access.js'
import { analyzeSchemaCompleteness } from './analyzers/schema-completeness.js'
import { analyzeContentExtractability } from './analyzers/content-extractability.js'
import { analyzeTechnicalSeo } from './analyzers/technical-seo.js'
import { analyzeAgentSkillExposure } from './analyzers/agent-skill-exposure.js'
import { getVisibleText, parseJsonLdScripts, countWords } from './analyzers/helpers.js'
import { FACTOR_DEFINITIONS, OPTIONAL_FACTOR_DEFINITIONS, scoreFactors } from './scoring.js'
import type { Analyzer, AuditContext, AuditReport, RunAeoAuditOptions, ScoredFactor } from './types.js'
Expand All @@ -39,6 +40,7 @@ const ANALYZER_BY_ID: Record<string, Analyzer> = {
'schema-completeness': analyzeSchemaCompleteness,
'content-extractability': analyzeContentExtractability,
'technical-seo': analyzeTechnicalSeo,
'agent-skill-exposure': analyzeAgentSkillExposure,
}

const ALL_FACTOR_IDS = new Set([
Expand Down Expand Up @@ -88,14 +90,19 @@ export async function runAeoAudit(rawUrl: string, options: RunAeoAuditOptions =
}

// Determine which factors to run
let activeDefs = [...FACTOR_DEFINITIONS]
const enabledOptional = new Set<string>()
if (options.includeGeo) enabledOptional.add('geographic-signals')
if (options.includeAgentSkills) enabledOptional.add('agent-skill-exposure')

if (options.includeGeo) {
activeDefs = [...activeDefs, ...OPTIONAL_FACTOR_DEFINITIONS]
}
let activeDefs = [
...FACTOR_DEFINITIONS,
...OPTIONAL_FACTOR_DEFINITIONS.filter((def) => enabledOptional.has(def.id)),
]

if (selectedFactors.length > 0) {
activeDefs = activeDefs.filter((def) => selectedFactors.includes(def.id))
// Explicit --factors overrides opt-in flags: let users target any factor by id.
const universe = [...FACTOR_DEFINITIONS, ...OPTIONAL_FACTOR_DEFINITIONS]
activeDefs = universe.filter((def) => selectedFactors.includes(def.id))
}

const rawFactorResults = await Promise.all(
Expand Down
1 change: 1 addition & 0 deletions src/scoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const FACTOR_DEFINITIONS: FactorDefinition[] = [

export const OPTIONAL_FACTOR_DEFINITIONS: FactorDefinition[] = [
{ id: 'geographic-signals', name: 'Geographic Signals', weight: 7 },
{ id: 'agent-skill-exposure', name: 'Agent Skill Exposure', weight: 6 },
]

export function scoreToGrade(score: number): string {
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export interface AuditContext {
export interface RunAeoAuditOptions {
factors?: string[] | null
includeGeo?: boolean
includeAgentSkills?: boolean
}

export interface RawFactorResult extends AnalysisResult {
Expand Down
Loading
Loading