mod chunks;
mod citation;
mod knowledge_graph;
pub mod module;
mod readability;
pub use chunks::{
ai_chunk_recommendation, ai_chunk_section_heading, ChunkAnalysis, ChunkQuality,
ChunkRecommendationKind, ChunkSectionKind, ContentSection,
};
pub use citation::CitationAnalysis;
pub use knowledge_graph::{
ai_kg_link_suggestion_reason, EntityRelationship, EntitySource, GraphEntity, KgSuggestionKind,
KnowledgeGraphAnalysis, LinkSuggestion,
};
pub use module::AiVisibilityModule;
pub use readability::ReadabilityAnalysis;
use serde::{Deserialize, Serialize};
use crate::audit::AuditReport;
use crate::seo::schema::SchemaType;
use crate::taxonomy::module_score_grade;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AiVisibilityAnalysis {
pub score: u32,
pub grade: String,
pub readability: ReadabilityAnalysis,
pub citation: CitationAnalysis,
pub chunks: ChunkAnalysis,
pub knowledge_graph: KnowledgeGraphAnalysis,
pub policy: PolicyAnalysis,
pub disclaimer: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyAnalysis {
pub dimension: DimensionScore,
pub blocks_ai_citation: bool,
pub blocks_ai_training: bool,
pub blocks_all: bool,
pub blocked_ai_bot_count: usize,
pub inferred_policy: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DimensionKind {
Readability,
Citability,
Chunks,
KnowledgeGraph,
Policy,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DimensionScore {
pub kind: DimensionKind,
pub name: String,
pub score: u32,
pub label: String,
pub signals: Vec<AiSignal>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AiSignalKind {
HeadingStructure,
ContentVolume,
ParagraphStructure,
Lists,
Tables,
SchemaCoverage,
ExtractableAnswers,
SummaryMeta,
LanguageDeclaration,
DefinitionPatterns,
Encryption,
PublisherIdentity,
ArticleStructure,
PublicationDate,
CanonicalUrl,
SnippetQuality,
QuestionAnswerPattern,
ContentDepth,
SharingMetadata,
ThematicContext,
TechnicalTrust,
SectionCount,
SectionLength,
NoOversizedSections,
FewFragments,
HierarchicalStructure,
SemanticHtml,
SectionDensity,
ContentBoundary,
EntitiesDetected,
SchemaOrgEntities,
Relationships,
TypeDiversity,
BreadcrumbHierarchy,
LinkingDensity,
PropertyCompleteness,
RobotsReachable,
NoWildcardBlock,
AiSearchReachable,
ExplicitAiPolicy,
SitemapReference,
MetaRobotsAllowed,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AiSignalValues {
#[serde(skip_serializing_if = "Option::is_none")]
pub heading_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_heading_depth: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub word_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub paragraph_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub avg_paragraph_len: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub list_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub schema_type_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_faq: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_howto: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_schema: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub meta_desc_len: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_meta_description: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub section_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_author: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_org: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub short_paragraph_ratio: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_lists: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub security_score: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub a11y_score: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub optimal_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub too_long_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub too_short_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub avg_words: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub entity_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub schema_entity_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub relationship_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unique_type_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub internal_links: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub entities_with_props: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inferred_policy: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub blocked_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub blocks_all: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub blocks_citation: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_robots: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AiSignal {
pub kind: AiSignalKind,
pub name: String,
pub present: bool,
pub weight: f32,
pub detail: String,
#[serde(default)]
pub values: AiSignalValues,
}
impl AiSignal {
pub(crate) fn new(
kind: AiSignalKind,
present: bool,
weight: f32,
values: AiSignalValues,
) -> Self {
let (name, detail) = ai_signal_text(kind, present, &values, true);
AiSignal {
kind,
name,
present,
weight,
detail,
values,
}
}
}
const DISCLAIMER_DE: &str = "Diese Bewertung basiert auf heuristischen Signalen zur \
AI-Sichtbarkeit. Sie bewertet, wie gut Inhalte für die maschinelle Extraktion \
und Zitierung durch KI-Systeme aufbereitet sind — nicht deren inhaltliche Qualität.";
const DISCLAIMER_EN: &str = "This assessment is based on heuristic signals for \
AI visibility. It evaluates how well content is prepared for machine extraction \
and citation by AI systems — not its factual quality.";
pub fn ai_disclaimer(en: bool) -> String {
if en { DISCLAIMER_EN } else { DISCLAIMER_DE }.to_string()
}
pub fn ai_dimension_name(kind: DimensionKind, en: bool) -> &'static str {
match (kind, en) {
(DimensionKind::Readability, true) => "AI readability",
(DimensionKind::Readability, false) => "KI-Lesbarkeit",
(DimensionKind::Citability, true) => "Citability",
(DimensionKind::Citability, false) => "Zitierbarkeit",
(DimensionKind::Chunks, true) => "Technical AI readability",
(DimensionKind::Chunks, false) => "Technische KI-Lesbarkeit",
(DimensionKind::KnowledgeGraph, true) => "Structured data",
(DimensionKind::KnowledgeGraph, false) => "Strukturierte Daten",
(DimensionKind::Policy, true) => "AI policy",
(DimensionKind::Policy, false) => "AI-Policy",
}
}
pub fn ai_no_data_label(en: bool) -> String {
if en { "No data" } else { "Keine Daten" }.to_string()
}
pub fn ai_dimension_label(score: u32, en: bool) -> String {
crate::registry::FIVE_BAND
.label(score as f32, en)
.to_string()
}
pub fn ai_signal_text(
kind: AiSignalKind,
present: bool,
values: &AiSignalValues,
en: bool,
) -> (String, String) {
use AiSignalKind::*;
let name: String = match (kind, en) {
(HeadingStructure, true) => "Heading structure".into(),
(HeadingStructure, false) => "Überschriftenstruktur".into(),
(ContentVolume, true) => "Content volume".into(),
(ContentVolume, false) => "Inhaltsumfang".into(),
(ParagraphStructure, true) => "Paragraph structure".into(),
(ParagraphStructure, false) => "Absatzstruktur".into(),
(Lists, true) => "Lists / bullet points".into(),
(Lists, false) => "Listen / Aufzählungen".into(),
(Tables, true) => "Tables".into(),
(Tables, false) => "Tabellen".into(),
(SchemaCoverage, true) => "Schema coverage".into(),
(SchemaCoverage, false) => "Schema-Abdeckung".into(),
(ExtractableAnswers, true) => "Extractable answers".into(),
(ExtractableAnswers, false) => "Extrahierbare Antworten".into(),
(SummaryMeta, true) => "Summary (meta)".into(),
(SummaryMeta, false) => "Zusammenfassung (Meta)".into(),
(LanguageDeclaration, true) => "Language declaration".into(),
(LanguageDeclaration, false) => "Sprachdeklaration".into(),
(DefinitionPatterns, true) => "Definition patterns".into(),
(DefinitionPatterns, false) => "Definitionsmuster".into(),
(Encryption, true) => "Encryption".into(),
(Encryption, false) => "Verschlüsselung".into(),
(PublisherIdentity, true) => "Publisher identity".into(),
(PublisherIdentity, false) => "Herausgeber-Identität".into(),
(ArticleStructure, true) => "Article structure".into(),
(ArticleStructure, false) => "Artikelstruktur".into(),
(PublicationDate, true) => "Publication date".into(),
(PublicationDate, false) => "Publikationsdatum".into(),
(CanonicalUrl, true) => "Canonical URL".into(),
(CanonicalUrl, false) => "Kanonische URL".into(),
(SnippetQuality, true) => "Snippet quality".into(),
(SnippetQuality, false) => "Snippet-Qualität".into(),
(QuestionAnswerPattern, true) => "Question-answer pattern".into(),
(QuestionAnswerPattern, false) => "Frage-Antwort-Muster".into(),
(ContentDepth, true) => "Content depth".into(),
(ContentDepth, false) => "Inhaltliche Tiefe".into(),
(SharingMetadata, true) => "Sharing metadata".into(),
(SharingMetadata, false) => "Teilen-Metadaten".into(),
(ThematicContext, true) => "Thematic context".into(),
(ThematicContext, false) => "Thematische Einordnung".into(),
(TechnicalTrust, true) => "Technical trust".into(),
(TechnicalTrust, false) => "Technisches Vertrauen".into(),
(SectionCount, true) => "Section count".into(),
(SectionCount, false) => "Abschnittszahl".into(),
(SectionLength, true) => "Heuristic: section length".into(),
(SectionLength, false) => "Heuristik: Abschnittslänge".into(),
(NoOversizedSections, true) => "No oversized sections".into(),
(NoOversizedSections, false) => "Keine Übergroßen Abschnitte".into(),
(FewFragments, true) => "Few fragments".into(),
(FewFragments, false) => "Wenig Fragmente".into(),
(HierarchicalStructure, true) => "Hierarchical structure".into(),
(HierarchicalStructure, false) => "Hierarchische Gliederung".into(),
(SemanticHtml, true) => "Semantic HTML".into(),
(SemanticHtml, false) => "Semantisches HTML".into(),
(SectionDensity, true) => "Section density".into(),
(SectionDensity, false) => "Abschnittsdichte".into(),
(ContentBoundary, true) => "Content boundary".into(),
(ContentBoundary, false) => "Content-Begrenzung".into(),
(EntitiesDetected, true) => "Entities detected".into(),
(EntitiesDetected, false) => "Entitäten erkannt".into(),
(SchemaOrgEntities, true) => "Schema.org entities".into(),
(SchemaOrgEntities, false) => "Schema.org-Entitäten".into(),
(Relationships, true) => "Relationships".into(),
(Relationships, false) => "Beziehungen".into(),
(TypeDiversity, true) => "Type diversity".into(),
(TypeDiversity, false) => "Typen-Vielfalt".into(),
(BreadcrumbHierarchy, true) => "Breadcrumb hierarchy".into(),
(BreadcrumbHierarchy, false) => "Breadcrumb-Hierarchie".into(),
(LinkingDensity, true) => "Linking density".into(),
(LinkingDensity, false) => "Verlinkungsdichte".into(),
(PropertyCompleteness, true) => "Property completeness".into(),
(PropertyCompleteness, false) => "Eigenschafts-Vollständigkeit".into(),
(RobotsReachable, true) => "robots.txt reachable".into(),
(RobotsReachable, false) => "robots.txt erreichbar".into(),
(NoWildcardBlock, true) => "No wildcard block".into(),
(NoWildcardBlock, false) => "Kein Wildcard-Block".into(),
(AiSearchReachable, true) => "AI search reachable".into(),
(AiSearchReachable, false) => "KI-Suche erreichbar".into(),
(ExplicitAiPolicy, true) => "Explicit AI policy".into(),
(ExplicitAiPolicy, false) => "Explizite AI-Policy".into(),
(SitemapReference, true) => "Sitemap reference".into(),
(SitemapReference, false) => "Sitemap-Verweis".into(),
(MetaRobotsAllowed, true) => "Meta robots allowed".into(),
(MetaRobotsAllowed, false) => "Meta-Robots freigegeben".into(),
};
let detail = ai_signal_detail(kind, present, values, en);
(name, detail)
}
fn ai_signal_detail(
kind: AiSignalKind,
present: bool,
values: &AiSignalValues,
en: bool,
) -> String {
use AiSignalKind::*;
match kind {
HeadingStructure => readability::detail_heading_structure(present, values, en),
ContentVolume => readability::detail_content_volume(present, values, en),
ParagraphStructure => readability::detail_paragraph_structure(values, en),
Lists => readability::detail_lists(present, values, en),
Tables => readability::detail_tables(present, en),
SchemaCoverage => readability::detail_schema_coverage(values, en),
ExtractableAnswers => readability::detail_extractable_answers(values, en),
SummaryMeta => readability::detail_summary_meta(present, values, en),
LanguageDeclaration => readability::detail_language_declaration(present, en),
DefinitionPatterns => readability::detail_definition_patterns(present, en),
Encryption => citation::detail_encryption(present, en),
PublisherIdentity => citation::detail_publisher_identity(values, en),
ArticleStructure => citation::detail_article_structure(present, en),
PublicationDate => citation::detail_publication_date(present, en),
CanonicalUrl => citation::detail_canonical_url(present, en),
SnippetQuality => citation::detail_snippet_quality(values, en),
QuestionAnswerPattern => citation::detail_question_answer(present, en),
ContentDepth => citation::detail_content_depth(present, values, en),
SharingMetadata => citation::detail_sharing_metadata(present, en),
ThematicContext => citation::detail_thematic_context(present, en),
TechnicalTrust => citation::detail_technical_trust(present, values, en),
SectionCount => chunks::detail_section_count(values, en),
SectionLength => chunks::detail_section_length(values, en),
NoOversizedSections => chunks::detail_no_oversized(present, values, en),
FewFragments => chunks::detail_few_fragments(present, values, en),
HierarchicalStructure => chunks::detail_hierarchical(present, en),
SemanticHtml => chunks::detail_semantic_html(present, en),
SectionDensity => chunks::detail_section_density(present, values, en),
ContentBoundary => chunks::detail_content_boundary(present, en),
EntitiesDetected => knowledge_graph::detail_entities_detected(present, values, en),
SchemaOrgEntities => knowledge_graph::detail_schema_entities(present, values, en),
Relationships => knowledge_graph::detail_relationships(present, values, en),
TypeDiversity => knowledge_graph::detail_type_diversity(present, values, en),
BreadcrumbHierarchy => knowledge_graph::detail_breadcrumb(present, en),
LinkingDensity => knowledge_graph::detail_linking_density(present, values, en),
PropertyCompleteness => knowledge_graph::detail_property_completeness(present, values, en),
RobotsReachable => detail_robots_reachable(present, en),
NoWildcardBlock => detail_no_wildcard_block(present, en),
AiSearchReachable => detail_ai_search_reachable(values, en),
ExplicitAiPolicy => detail_explicit_ai_policy(present, values, en),
SitemapReference => detail_sitemap_reference(present, en),
MetaRobotsAllowed => detail_meta_robots_allowed(present, en),
}
}
pub fn analyze_ai_visibility(report: &AuditReport) -> AiVisibilityAnalysis {
let readability_input = build_readability_input(report);
let readability = readability::analyze_readability(&readability_input);
let citation_input = build_citation_input(report);
let citation = citation::analyze_citation(&citation_input);
let chunk_input = build_chunk_input(report);
let chunks = chunks::analyze_chunks(&chunk_input);
let kg_input = build_knowledge_graph_input(report);
let knowledge_graph = knowledge_graph::analyze_knowledge_graph(&kg_input);
let policy = analyze_policy(report);
let score = weighted_average(&[
(readability.dimension.score, 25),
(citation.dimension.score, 25),
(chunks.dimension.score, 20),
(knowledge_graph.dimension.score, 15),
(policy.dimension.score, 15),
]);
AiVisibilityAnalysis {
score,
grade: module_score_grade(score).to_string(),
readability,
citation,
chunks,
knowledge_graph,
policy,
disclaimer: ai_disclaimer(true),
}
}
pub fn analyze_ai_visibility_batch(reports: &[AuditReport]) -> AiVisibilityAnalysis {
if reports.is_empty() {
return empty_analysis();
}
let analyses: Vec<AiVisibilityAnalysis> = reports.iter().map(analyze_ai_visibility).collect();
let avg_score = analyses.iter().map(|a| a.score).sum::<u32>() / analyses.len() as u32;
let mut result = analyses.into_iter().next().unwrap();
result.score = avg_score;
result.grade = module_score_grade(avg_score).to_string();
result
}
fn analyze_policy(report: &AuditReport) -> PolicyAnalysis {
let mut signals = Vec::new();
let (
has_robots,
blocks_citation,
blocks_training,
blocks_all,
blocked_count,
has_sitemap_in_robots,
inferred_policy,
) = if let Some(seo) = &report.discoverability.seo {
if let Some(robots) = &seo.robots {
let blocked = robots
.groups
.iter()
.filter(|g| {
matches!(
g.bot_class,
crate::seo::robots::BotClass::AiTraining
| crate::seo::robots::BotClass::AiCitation
| crate::seo::robots::BotClass::AiMixed
| crate::seo::robots::BotClass::UnknownAi
) && g.disallows.iter().any(|d| d == "/")
})
.count();
(
robots.fetched,
robots.blocks_ai_citation,
robots.blocks_ai_training,
robots.has_wildcard_disallow_all,
blocked,
!robots.sitemaps.is_empty(),
robots.inferred_policy.clone(),
)
} else {
(
false,
false,
false,
false,
0,
false,
"No robots.txt".to_string(),
)
}
} else {
(false, false, false, false, 0, false, String::new())
};
signals.push(AiSignal::new(
AiSignalKind::RobotsReachable,
has_robots,
0.15,
AiSignalValues::default(),
));
signals.push(AiSignal::new(
AiSignalKind::NoWildcardBlock,
!blocks_all,
0.20,
AiSignalValues::default(),
));
signals.push(AiSignal::new(
AiSignalKind::AiSearchReachable,
!blocks_citation,
0.25,
AiSignalValues {
blocks_all: Some(blocks_all),
blocks_citation: Some(blocks_citation),
..Default::default()
},
));
let has_explicit_policy = has_robots && blocked_count > 0;
signals.push(AiSignal::new(
AiSignalKind::ExplicitAiPolicy,
has_explicit_policy,
0.15,
AiSignalValues {
has_robots: Some(has_robots),
blocked_count: Some(blocked_count as u32),
inferred_policy: Some(inferred_policy.clone()),
..Default::default()
},
));
signals.push(AiSignal::new(
AiSignalKind::SitemapReference,
has_sitemap_in_robots,
0.15,
AiSignalValues::default(),
));
let has_meta_robots_issue = report.discoverability.seo.as_ref().is_some_and(|seo| {
seo.technical
.issues
.iter()
.any(|i| i.issue_type.contains("noindex") || i.issue_type.contains("nofollow"))
});
signals.push(AiSignal::new(
AiSignalKind::MetaRobotsAllowed,
!has_meta_robots_issue,
0.10,
AiSignalValues::default(),
));
PolicyAnalysis {
dimension: build_dimension(DimensionKind::Policy, &signals),
blocks_ai_citation: blocks_citation,
blocks_ai_training: blocks_training,
blocks_all,
blocked_ai_bot_count: blocked_count,
inferred_policy,
}
}
fn detail_robots_reachable(present: bool, en: bool) -> String {
if present {
if en {
"robots.txt present and readable".into()
} else {
"robots.txt vorhanden und lesbar".into()
}
} else if en {
"robots.txt not reachable — AI crawlers have no policy info".into()
} else {
"robots.txt nicht erreichbar — AI-Crawler haben keine Policy-Info".into()
}
}
fn detail_no_wildcard_block(present: bool, en: bool) -> String {
if !present {
if en {
"Disallow: / for all crawlers — blocks AI systems too".into()
} else {
"Disallow: / für alle Crawler — blockiert auch AI-Systeme".into()
}
} else if en {
"No global block — crawlers generally have access".into()
} else {
"Kein globaler Block — Crawler haben grundsätzlich Zugang".into()
}
}
fn detail_ai_search_reachable(values: &AiSignalValues, en: bool) -> String {
let blocks_all = values.blocks_all.unwrap_or(false);
let blocks_citation = values.blocks_citation.unwrap_or(false);
if blocks_all {
if en {
"All crawlers blocked (Disallow: *) — AI search blocked too".into()
} else {
"Alle Crawler gesperrt (Disallow: *) — auch KI-Suche blockiert".into()
}
} else if blocks_citation {
if en {
"AI search bots (PerplexityBot, Amazonbot etc.) blocked — content does not appear in AI answers".into()
} else {
"KI-Suchbots (PerplexityBot, Amazonbot etc.) blockiert — Inhalte erscheinen nicht in KI-Antworten".into()
}
} else if en {
"AI search bots not blocked — content available for AI answers".into()
} else {
"KI-Suchbots nicht blockiert — Inhalte für KI-Antworten verfügbar".into()
}
}
fn detail_explicit_ai_policy(present: bool, values: &AiSignalValues, en: bool) -> String {
let has_robots = values.has_robots.unwrap_or(false);
let blocked_count = values.blocked_count.unwrap_or(0);
let inferred_policy = values.inferred_policy.as_deref().unwrap_or("");
if present {
if en {
format!(
"Policy defined: {} — {} AI bots addressed",
inferred_policy, blocked_count
)
} else {
format!(
"Policy definiert: {} — {} AI-Bots adressiert",
inferred_policy, blocked_count
)
}
} else if has_robots {
if en {
"No explicit AI crawler rules in robots.txt".into()
} else {
"Keine explizite AI-Crawler-Regelung in robots.txt".into()
}
} else if en {
"No robots.txt — no AI policy definable".into()
} else {
"Keine robots.txt — keine AI-Policy definierbar".into()
}
}
fn detail_sitemap_reference(present: bool, en: bool) -> String {
if present {
if en {
"Sitemap linked in robots.txt — eases AI crawling".into()
} else {
"Sitemap in robots.txt verlinkt — erleichtert AI-Crawling".into()
}
} else if en {
"No sitemap in robots.txt — AI crawlers must discover pages themselves".into()
} else {
"Keine Sitemap in robots.txt — AI-Crawler müssen Seiten selbst entdecken".into()
}
}
fn detail_meta_robots_allowed(present: bool, en: bool) -> String {
if !present {
if en {
"noindex/nofollow detected — page avoided by AI crawlers".into()
} else {
"noindex/nofollow erkannt — Seite wird von AI-Crawlern gemieden".into()
}
} else if en {
"No indexing block via meta tags".into()
} else {
"Keine Indexierungs-Blockade via Meta-Tags".into()
}
}
fn build_readability_input(report: &AuditReport) -> readability::ReadabilityInput {
let seo = report.discoverability.seo.as_ref();
let word_count = seo.map_or(0, |s| s.technical.word_count);
let headings = seo.map_or(&[][..], |s| &s.headings.headings);
let heading_count = headings.len();
let max_heading_depth = headings.iter().map(|h| h.level as u32).max().unwrap_or(0);
let has_schema = seo.is_some_and(|s| s.structured_data.has_structured_data);
let schema_types = seo.map_or(&[][..], |s| &s.structured_data.types);
let schema_type_count = schema_types.len();
let has_faq_schema = schema_types
.iter()
.any(|t| matches!(t, SchemaType::FAQPage));
let has_howto_schema = schema_types.iter().any(|t| matches!(t, SchemaType::HowTo));
let has_meta_description = seo
.and_then(|s| s.meta.description.as_ref())
.is_some_and(|d| !d.is_empty());
let meta_desc_len = seo
.and_then(|s| s.meta.description.as_ref())
.map_or(0, |d| d.len());
let has_lang = seo.is_some_and(|s| s.technical.has_lang);
let paragraph_count = if heading_count > 0 {
heading_count as u32 * 2 } else if word_count > 100 {
(word_count / 80).max(1) } else {
1
};
let avg_paragraph_len = word_count
.checked_div(paragraph_count)
.unwrap_or(word_count);
let has_lists = has_faq_schema || word_count > 500;
let list_count = if has_faq_schema {
2
} else if word_count > 500 {
1
} else {
0
};
let has_tables = false;
readability::ReadabilityInput {
word_count,
heading_count,
max_heading_depth,
has_lists,
list_count,
has_tables,
has_schema,
schema_type_count,
has_faq_schema,
has_howto_schema,
has_meta_description,
meta_desc_len,
has_lang,
paragraph_count,
avg_paragraph_len,
has_definition_patterns: false, }
}
fn build_citation_input(report: &AuditReport) -> citation::CitationInput {
let seo = report.discoverability.seo.as_ref();
let schema_types = seo.map_or(&[][..], |s| &s.structured_data.types);
let has_author_schema = schema_types.iter().any(|t| matches!(t, SchemaType::Person));
let has_org_schema = schema_types.iter().any(SchemaType::is_organization_like);
let has_article_schema = schema_types.iter().any(|t| {
matches!(
t,
SchemaType::Article | SchemaType::BlogPosting | SchemaType::NewsArticle
)
});
let has_faq_schema = schema_types
.iter()
.any(|t| matches!(t, SchemaType::FAQPage));
let has_breadcrumb = schema_types
.iter()
.any(|t| matches!(t, SchemaType::BreadcrumbList));
let has_canonical = seo.is_some_and(|s| s.technical.has_canonical);
let has_og_meta = seo.is_some_and(|s| {
s.social
.open_graph
.as_ref()
.is_some_and(|og| og.title.is_some() && og.description.is_some())
});
let word_count = seo.map_or(0, |s| s.technical.word_count);
let heading_count = seo.map_or(0, |s| s.headings.headings.len());
let has_date_published = seo.is_some_and(|s| {
s.structured_data.json_ld.iter().any(|ld| {
ld.content.get("datePublished").is_some() || ld.content.get("dateCreated").is_some()
})
});
let has_lists = seo.is_some() && word_count > 500;
let paragraph_count = if heading_count > 0 {
heading_count as u32 * 2
} else if word_count > 100 {
(word_count / 80).max(1)
} else {
1
};
let avg_len = word_count
.checked_div(paragraph_count)
.unwrap_or(word_count);
let short_paragraph_ratio = if avg_len <= 100 {
0.6
} else if avg_len <= 150 {
0.4
} else {
0.2
};
citation::CitationInput {
has_https: report.url.starts_with("https://"),
has_author_schema,
has_org_schema,
has_article_schema,
has_canonical,
has_og_meta,
word_count,
heading_count,
security_score: report.security.as_ref().map(|s| s.score),
a11y_score: report.accessibility.score,
has_faq_schema,
has_lists,
short_paragraph_ratio,
has_date_published,
has_breadcrumb,
}
}
fn build_chunk_input(report: &AuditReport) -> chunks::ChunkInput {
let seo = report.discoverability.seo.as_ref();
let headings: Vec<chunks::HeadingInfo> = seo.map_or(vec![], |s| {
s.headings
.headings
.iter()
.map(|heading| chunks::HeadingInfo {
text: heading.text.clone(),
level: heading.level as u32,
word_count_after: heading.word_count_after,
})
.collect()
});
let total_word_count = seo.map_or(0, |s| s.technical.word_count);
let has_nav_landmarks = report
.accessibility
.wcag_results
.violations
.iter()
.all(|v| !v.rule_name.contains("Landmark"));
let has_article_tag = seo.is_some_and(|s| {
s.structured_data.types.iter().any(|t| {
matches!(
t,
SchemaType::Article | SchemaType::BlogPosting | SchemaType::NewsArticle
)
})
});
let has_section_tags = headings.len() >= 3; let has_semantic_html = has_nav_landmarks && (has_article_tag || has_section_tags);
chunks::ChunkInput {
headings,
total_word_count,
has_semantic_html,
has_article_tag,
has_section_tags,
}
}
fn build_knowledge_graph_input(report: &AuditReport) -> knowledge_graph::KnowledgeGraphInput {
let seo = report.discoverability.seo.as_ref();
let schemas: Vec<knowledge_graph::SchemaEntity> = seo.map_or(vec![], |s| {
s.structured_data
.json_ld
.iter()
.map(|ld| {
let name = ld
.content
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let mut properties = Vec::new();
for key in &[
"author",
"publisher",
"creator",
"provider",
"isPartOf",
"mainEntityOfPage",
"about",
"url",
"description",
] {
if let Some(val) = ld.content.get(*key) {
let val_str = if let Some(s) = val.as_str() {
s.to_string()
} else if let Some(obj) = val.as_object() {
obj.get("name")
.and_then(|n| n.as_str())
.unwrap_or_default()
.to_string()
} else {
continue;
};
if !val_str.is_empty() {
properties.push((key.to_string(), val_str));
}
}
}
knowledge_graph::SchemaEntity {
schema_type: ld.schema_type.clone(),
name,
properties,
}
})
.collect()
});
let page_title = seo.and_then(|s| s.meta.title.clone());
let site_name = seo.and_then(|s| {
s.social
.open_graph
.as_ref()
.and_then(|og| og.site_name.clone())
});
let internal_links = seo.map_or(0, |s| s.technical.internal_links);
let has_breadcrumb = seo.is_some_and(|s| {
s.structured_data
.types
.iter()
.any(|t| matches!(t, SchemaType::BreadcrumbList))
});
knowledge_graph::KnowledgeGraphInput {
schemas,
page_title,
site_name,
internal_links,
has_breadcrumb,
}
}
pub(crate) fn build_dimension(kind: DimensionKind, signals: &[AiSignal]) -> DimensionScore {
let name = ai_dimension_name(kind, true).to_string();
if signals.is_empty() {
return DimensionScore {
kind,
name,
score: 0,
label: ai_no_data_label(true),
signals: vec![],
};
}
let total_weight: f32 = signals.iter().map(|s| s.weight).sum();
let score = if total_weight > 0.0 {
let raw: f32 = signals
.iter()
.map(|s| {
if s.present {
s.weight / total_weight * 100.0
} else {
0.0
}
})
.sum();
raw.round() as u32
} else {
0
};
DimensionScore {
kind,
name,
score,
label: ai_dimension_label(score, true),
signals: signals.to_vec(),
}
}
fn weighted_average(items: &[(u32, u32)]) -> u32 {
let total_weight: u32 = items.iter().map(|(_, w)| w).sum();
if total_weight == 0 {
return 0;
}
let sum: u32 = items.iter().map(|(s, w)| s * w).sum();
(sum as f64 / total_weight as f64).round() as u32
}
fn empty_analysis() -> AiVisibilityAnalysis {
let empty_dim = |kind: DimensionKind| DimensionScore {
kind,
name: String::new(),
score: 0,
label: ai_no_data_label(true),
signals: vec![],
};
AiVisibilityAnalysis {
score: 0,
grade: "F".into(),
readability: ReadabilityAnalysis {
dimension: empty_dim(DimensionKind::Readability),
},
citation: CitationAnalysis {
dimension: empty_dim(DimensionKind::Citability),
},
chunks: ChunkAnalysis {
dimension: empty_dim(DimensionKind::Chunks),
sections: vec![],
recommendation_kind: chunks::ChunkRecommendationKind::TooLittleStructure,
recommendation_counts: (0, 0, 0),
recommendation: String::new(),
},
knowledge_graph: KnowledgeGraphAnalysis {
dimension: empty_dim(DimensionKind::KnowledgeGraph),
entities: vec![],
relationships: vec![],
link_suggestions: vec![],
},
policy: PolicyAnalysis {
dimension: empty_dim(DimensionKind::Policy),
blocks_ai_citation: false,
blocks_ai_training: false,
blocks_all: false,
blocked_ai_bot_count: 0,
inferred_policy: String::new(),
},
disclaimer: DISCLAIMER_EN.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit::AuditReport;
use crate::audit::ViolationStatistics;
use crate::cli::WcagLevel;
use crate::wcag::WcagResults;
fn minimal_report() -> AuditReport {
AuditReport {
url: "https://example.com".into(),
wcag_level: WcagLevel::AA,
timestamp: chrono::Utc::now(),
accessibility: crate::audit::AccessibilitySection {
wcag_results: WcagResults::new(),
score: 95.0,
grade: "A".into(),
certificate: "SEHR GUT".into(),
statistics: ViolationStatistics {
critical: 0,
high: 0,
medium: 0,
low: 0,
total: 0,
},
nodes_analyzed: 100,
execution: Default::default(),
},
duration_ms: 1000,
performance: None,
security: None,
experience: crate::audit::ExperienceSection::default(),
ux: None,
journey: None,
discoverability: crate::audit::DiscoverabilitySection {
seo: None,
ai_visibility: None,
content_visibility: None,
source_quality: None,
tech_stack: None,
},
page_screenshots: None,
dual_viewport: None,
viewport_scores: None,
throttled_performance: vec![],
patterns: None,
screenshot_status: Default::default(),
best_practices: None,
commerce: None,
consent_banner_detected: false,
consent_banner_cmp: None,
consent_banner_dismissed: false,
consent_privacy: None,
accessibility_journey: None,
interactive_findings: Vec::new(),
screen_reader_audit: None,
}
}
#[test]
fn test_minimal_report_produces_scores() {
let report = minimal_report();
let analysis = analyze_ai_visibility(&report);
assert!(analysis.score <= 100);
assert!(!analysis.disclaimer.is_empty());
assert!(!analysis.grade.is_empty());
}
#[test]
fn test_empty_batch_returns_zero() {
let analysis = analyze_ai_visibility_batch(&[]);
assert_eq!(analysis.score, 0);
}
#[test]
fn test_batch_with_reports() {
let reports = vec![minimal_report(), minimal_report()];
let analysis = analyze_ai_visibility_batch(&reports);
assert!(analysis.score <= 100);
}
#[test]
fn test_grade_mapping() {
assert_eq!(module_score_grade(95), "A");
assert_eq!(module_score_grade(80), "B");
assert_eq!(module_score_grade(65), "C");
assert_eq!(module_score_grade(45), "D");
assert_eq!(module_score_grade(20), "F");
}
#[test]
fn test_weighted_average() {
assert_eq!(weighted_average(&[(100, 50), (0, 50)]), 50);
assert_eq!(weighted_average(&[(100, 100)]), 100);
assert_eq!(weighted_average(&[]), 0);
}
fn contains_german(s: &str) -> bool {
s.contains(['ä', 'ö', 'ü', 'Ä', 'Ö', 'Ü', 'ß'])
}
#[test]
fn struct_is_canonical_english() {
use crate::seo::{
HeadingStructure, MetaTags, SeoAnalysis, SocialTags, StructuredData, TechnicalSeo,
};
let mut report = minimal_report();
report.discoverability.seo = Some(SeoAnalysis {
meta: MetaTags::default(),
headings: HeadingStructure::default(),
technical: TechnicalSeo::default(),
social: SocialTags::default(),
structured_data: StructuredData::default(),
score: 40,
content_profile: None,
robots: None,
page_health: None,
serp: None,
meta_issues: vec![],
image_efficiency: None,
});
let analysis = analyze_ai_visibility(&report);
assert!(!contains_german(&analysis.disclaimer));
let dims = [
&analysis.readability.dimension,
&analysis.citation.dimension,
&analysis.chunks.dimension,
&analysis.knowledge_graph.dimension,
&analysis.policy.dimension,
];
for dim in dims {
assert!(!contains_german(&dim.name), "dim name: {:?}", dim.name);
assert!(!contains_german(&dim.label), "dim label: {:?}", dim.label);
for s in &dim.signals {
assert!(!contains_german(&s.name), "signal name: {:?}", s.name);
assert!(!contains_german(&s.detail), "signal detail: {:?}", s.detail);
}
}
assert!(!contains_german(&analysis.chunks.recommendation));
for sec in &analysis.chunks.sections {
assert!(!contains_german(&sec.heading));
}
for s in &analysis.knowledge_graph.link_suggestions {
assert!(!contains_german(&s.reason));
}
}
#[test]
fn pdf_layer_re_derives_german() {
assert_eq!(
ai_dimension_name(DimensionKind::Readability, false),
"KI-Lesbarkeit"
);
assert_eq!(ai_dimension_label(95, false), "Sehr gut");
assert!(ai_disclaimer(false).contains("heuristischen"));
let (name, detail) = ai_signal_text(
AiSignalKind::AiSearchReachable,
false,
&AiSignalValues {
blocks_all: Some(false),
blocks_citation: Some(true),
..Default::default()
},
false,
);
assert_eq!(name, "KI-Suche erreichbar");
assert!(detail.contains("KI-Suchbots"));
}
}