use serde::{Deserialize, Serialize};
use super::SeoAnalysis;
use crate::seo::schema::SchemaType;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SeoMaturityLevel {
Basic,
Standard,
Advanced,
Professional,
}
impl SeoMaturityLevel {
pub fn label(&self) -> &'static str {
match self {
Self::Basic => "Basis",
Self::Standard => "Standard",
Self::Advanced => "Fortgeschritten",
Self::Professional => "Professionell",
}
}
pub fn description(&self) -> &'static str {
match self {
Self::Basic => "Grundlegende SEO-Maßnahmen fehlen weitgehend.",
Self::Standard => "Basis-SEO ist vorhanden, fortgeschrittene Techniken fehlen.",
Self::Advanced => "Gute SEO-Abdeckung mit strukturierten Daten und Social Tags.",
Self::Professional => "Umfassende SEO-Strategie mit vollständiger Technik-Abdeckung.",
}
}
fn from_count(techniques: u32) -> Self {
match techniques {
0..=3 => Self::Basic,
4..=6 => Self::Standard,
7..=9 => Self::Advanced,
_ => Self::Professional,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SeoContentProfile {
pub content_identity: ContentIdentity,
pub page_classification: PageClassification,
pub schema_inventory: SchemaInventory,
pub signal_strength: SeoSignalStrength,
pub maturity: SeoMaturityLevel,
pub maturity_techniques: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum PageType {
Editorial,
StructuredContent,
MarketingLanding,
MediaHeavy,
Utility,
NavigationHub,
ThinContent,
}
impl PageType {
pub fn label(&self) -> &'static str {
match self {
Self::Editorial => "Editorial / Artikel",
Self::StructuredContent => "Strukturierter Wissensinhalt",
Self::MarketingLanding => "Marketing / Landing Page",
Self::MediaHeavy => "Medienorientierte Seite",
Self::Utility => "Transaktional / Utility",
Self::NavigationHub => "Navigations- / Hub-Seite",
Self::ThinContent => "Thin / Minimal Content",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageClassification {
pub primary_type: PageType,
pub attributes: Vec<String>,
pub content_depth_score: u32,
pub structural_richness_score: u32,
pub media_text_balance_score: u32,
pub intent_fit_score: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentIdentity {
pub summary: String,
pub site_name: Option<String>,
pub content_type: String,
pub language: Option<String>,
pub category_hints: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaInventory {
pub schemas: Vec<SchemaDetail>,
pub total_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaDetail {
pub schema_type: String,
pub fields_present: Vec<String>,
pub fields_missing: Vec<String>,
pub completeness_pct: u32,
pub extracted: SchemaExtracted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum SchemaExtracted {
Organization {
name: Option<String>,
url: Option<String>,
logo: Option<String>,
address: Option<String>,
phone: Option<String>,
},
LocalBusiness {
name: Option<String>,
address: Option<String>,
postal_code: Option<String>,
locality: Option<String>,
country: Option<String>,
phone: Option<String>,
url: Option<String>,
opening_hours: Box<Vec<String>>,
price_range: Option<String>,
same_as: Box<Vec<String>>,
logo: Option<String>,
image: Option<String>,
area_served: Box<Vec<String>>,
latitude: Option<f64>,
longitude: Option<f64>,
aggregate_rating: Option<String>,
},
Article {
headline: Option<String>,
author: Option<String>,
date_published: Option<String>,
date_modified: Option<String>,
publisher: Option<String>,
},
FAQPage {
question_count: usize,
questions: Vec<String>,
},
Product {
name: Option<String>,
price: Option<String>,
currency: Option<String>,
rating: Option<String>,
availability: Option<String>,
},
WebSite {
name: Option<String>,
url: Option<String>,
has_search_action: bool,
},
WebPage {
name: Option<String>,
url: Option<String>,
author: Option<String>,
in_language: Option<String>,
},
Service {
name: Option<String>,
address: Option<String>,
phone: Option<String>,
price_range: Option<String>,
area_served_count: usize,
},
BreadcrumbList {
item_count: usize,
},
Generic {
key_fields: Vec<(String, String)>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SeoSignalStrength {
pub categories: Vec<SignalCategory>,
pub overall_pct: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignalCategory {
pub name: String,
pub score_pct: u32,
pub checks: Vec<SignalCheck>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignalCheck {
pub label: String,
pub passed: bool,
pub detail: Option<String>,
}
pub fn build_content_profile(seo: &SeoAnalysis) -> SeoContentProfile {
let content_identity = build_identity(seo);
let page_classification = classify_page(seo);
let schema_inventory = build_schema_inventory(seo);
let signal_strength = build_signal_strength(seo);
let maturity_techniques = count_techniques(seo);
let maturity = SeoMaturityLevel::from_count(maturity_techniques);
SeoContentProfile {
content_identity,
page_classification,
schema_inventory,
signal_strength,
maturity,
maturity_techniques,
}
}
fn classify_page(seo: &SeoAnalysis) -> PageClassification {
let word_count = seo.technical.word_count;
let heading_count = seo.headings.total_count as u32;
let h2_plus_count = seo
.headings
.headings
.iter()
.filter(|h| h.level >= 2)
.count() as u32;
let internal_links = seo.technical.internal_links;
let has_faq = seo.structured_data.types.contains(&SchemaType::FAQPage);
let has_article_schema = seo.structured_data.types.iter().any(|t| {
matches!(
t,
SchemaType::Article | SchemaType::BlogPosting | SchemaType::NewsArticle
)
});
let has_product_schema = seo.structured_data.types.contains(&SchemaType::Product);
let has_search_schema = seo.structured_data.types.contains(&SchemaType::WebSite);
let content_depth_score = score_content_depth(word_count, h2_plus_count, heading_count);
let structural_richness_score = score_structural_richness(
heading_count,
h2_plus_count,
has_faq,
seo.headings.issues.len(),
);
let media_text_balance_score = score_media_text_balance(
word_count,
seo.social.open_graph.is_some(),
has_product_schema,
);
let is_utility_title = seo
.meta
.title
.as_deref()
.map(|t| t.contains("Kontakt") || t.contains("Login"))
.unwrap_or(false);
let looks_like_marketing_page = has_product_schema
|| seo.social.open_graph.is_some()
|| (word_count <= 700 && h2_plus_count >= 2);
let looks_like_hub_page = internal_links >= 40 || has_search_schema;
let primary_type = if has_article_schema || (word_count >= 1200 && h2_plus_count >= 3) {
PageType::Editorial
} else if has_faq || (word_count >= 600 && (h2_plus_count >= 4 || heading_count >= 6)) {
PageType::StructuredContent
} else if looks_like_hub_page {
PageType::NavigationHub
} else if is_utility_title {
PageType::Utility
} else if looks_like_marketing_page {
PageType::MarketingLanding
} else if word_count < 300 {
PageType::ThinContent
} else {
PageType::MediaHeavy
};
let intent_fit_score =
score_intent_fit(&primary_type, word_count, h2_plus_count, internal_links);
let mut attributes = Vec::new();
if word_count >= 1200 {
attributes.push("textstark".to_string());
} else if word_count < 300 {
attributes.push("sehr kurz".to_string());
}
if h2_plus_count >= 3 {
attributes.push("strukturiert".to_string());
}
if internal_links >= 30 {
attributes.push("navigationslastig".to_string());
}
if seo.social.open_graph.is_some() {
attributes.push("visuell geprägt".to_string());
}
if has_faq {
attributes.push("wissensorientiert".to_string());
}
if matches!(primary_type, PageType::MarketingLanding) {
attributes.push("conversionorientiert".to_string());
}
if matches!(primary_type, PageType::Utility) {
attributes.push("zweckgebunden".to_string());
}
if matches!(primary_type, PageType::ThinContent) {
attributes.push("dünn".to_string());
}
attributes.sort();
attributes.dedup();
PageClassification {
primary_type,
attributes,
content_depth_score,
structural_richness_score,
media_text_balance_score,
intent_fit_score,
}
}
fn score_content_depth(word_count: u32, h2_plus_count: u32, heading_count: u32) -> u32 {
let word_component = match word_count {
0..=149 => 10,
150..=299 => 25,
300..=599 => 45,
600..=1199 => 70,
_ => 90,
};
let structure_bonus = (h2_plus_count * 5 + heading_count.min(6) * 2).min(20);
(word_component + structure_bonus).min(100)
}
fn score_structural_richness(
heading_count: u32,
h2_plus_count: u32,
has_faq: bool,
heading_issues: usize,
) -> u32 {
let mut score = (heading_count * 10).min(60) + (h2_plus_count * 8).min(24);
if has_faq {
score += 8;
}
score = score.saturating_sub((heading_issues as u32) * 6);
score.min(100)
}
fn score_media_text_balance(word_count: u32, has_og: bool, has_product_schema: bool) -> u32 {
let mut score: u32 = match word_count {
0..=199 => 35,
200..=499 => 55,
500..=999 => 75,
_ => 85,
};
if has_og {
score = (score + 5).min(100);
}
if has_product_schema {
score = score.saturating_sub(5);
}
score
}
fn score_intent_fit(
page_type: &PageType,
word_count: u32,
h2_plus_count: u32,
internal_links: u32,
) -> u32 {
match page_type {
PageType::Editorial => {
if word_count >= 1000 && h2_plus_count >= 3 {
88
} else {
72
}
}
PageType::StructuredContent => {
if h2_plus_count >= 4 {
84
} else {
68
}
}
PageType::MarketingLanding => {
if (250..=900).contains(&word_count) {
80
} else {
65
}
}
PageType::MediaHeavy => 62,
PageType::Utility => 78,
PageType::NavigationHub => {
if internal_links >= 30 {
82
} else {
60
}
}
PageType::ThinContent => 28,
}
}
fn build_identity(seo: &SeoAnalysis) -> ContentIdentity {
let site_name = seo
.social
.open_graph
.as_ref()
.and_then(|og| og.site_name.clone())
.or_else(|| find_org_name(&seo.structured_data.json_ld));
let content_type = derive_content_type(seo);
let summary = build_summary(seo);
let category_hints: Vec<String> = seo
.structured_data
.types
.iter()
.map(|t| format!("{:?}", t))
.collect();
ContentIdentity {
summary,
site_name,
content_type,
language: seo.technical.lang.clone(),
category_hints,
}
}
fn find_org_name(json_ld: &[crate::seo::schema::JsonLdSchema]) -> Option<String> {
for schema in json_ld {
if schema
.schema_types
.iter()
.any(|t| t == "Organization" || t == "LocalBusiness" || t == "ProfessionalService")
|| schema.schema_type == "Organization"
|| schema.schema_type == "LocalBusiness"
|| schema.schema_type == "ProfessionalService"
{
if let Some(name) = schema.content["name"].as_str() {
return Some(name.to_string());
}
}
if let Some(graph) = schema.content["@graph"].as_array() {
for item in graph {
let item_type = item["@type"].as_str().unwrap_or("");
if item_type == "Organization" || item_type == "LocalBusiness" {
if let Some(name) = item["name"].as_str() {
return Some(name.to_string());
}
}
}
}
}
None
}
fn derive_content_type(seo: &SeoAnalysis) -> String {
if let Some(ref og) = seo.social.open_graph {
if let Some(ref og_type) = og.og_type {
match og_type.as_str() {
"article" => return "Artikel".to_string(),
"product" => return "Produkt".to_string(),
"profile" => return "Profil".to_string(),
"website" => {} _ => {}
}
}
}
for t in &seo.structured_data.types {
match t {
SchemaType::Article | SchemaType::BlogPosting | SchemaType::NewsArticle => {
return "Artikel / Blog".to_string()
}
SchemaType::Product => return "E-Commerce / Produkt".to_string(),
SchemaType::LocalBusiness => return "Lokales Unternehmen".to_string(),
SchemaType::Event => return "Veranstaltung".to_string(),
SchemaType::Recipe => return "Rezept".to_string(),
SchemaType::FAQPage => return "FAQ / Informationsseite".to_string(),
_ => {}
}
}
"Website".to_string()
}
fn build_summary(seo: &SeoAnalysis) -> String {
let title = seo.meta.title.as_deref().unwrap_or("");
let desc = seo.meta.description.as_deref().unwrap_or("");
if !title.is_empty() && !desc.is_empty() {
let combined = format!("{} — {}", title, desc);
if combined.len() > 150 {
let boundary = combined
.char_indices()
.take_while(|(i, _)| *i <= 147)
.last()
.map(|(i, _)| i)
.unwrap_or(0);
format!("{}...", &combined[..boundary])
} else {
combined
}
} else if !title.is_empty() {
title.to_string()
} else if !desc.is_empty() {
desc.to_string()
} else {
"Keine Meta-Informationen gefunden.".to_string()
}
}
fn build_schema_inventory(seo: &SeoAnalysis) -> SchemaInventory {
let mut schemas = Vec::new();
for json_ld in &seo.structured_data.json_ld {
if !json_ld.is_valid {
continue;
}
let root_types = if json_ld.schema_types.is_empty() {
vec![json_ld.schema_type.clone()]
} else {
json_ld.schema_types.clone()
};
for schema_type in root_types {
if let Some(d) = analyze_schema(&schema_type, &json_ld.content) {
schemas.push(d);
break;
}
}
if let Some(graph) = json_ld.content["@graph"].as_array() {
for item in graph {
let mut item_types = Vec::new();
if let Some(item_type) = item["@type"].as_str() {
item_types.push(item_type.to_string());
} else if let Some(arr) = item["@type"].as_array() {
item_types.extend(arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())));
}
for item_type in item_types {
if let Some(d) = analyze_schema(&item_type, item) {
schemas.push(d);
break;
}
}
}
}
}
let total_count = schemas.len();
SchemaInventory {
schemas,
total_count,
}
}
fn analyze_schema(schema_type: &str, content: &serde_json::Value) -> Option<SchemaDetail> {
let (expected, extracted) = match schema_type {
"Organization" => analyze_organization(content),
"LocalBusiness" => analyze_local_business(content),
"ProfessionalService"
| "Service"
| "WebDesignCompany"
| "LegalService"
| "AccountingService"
| "FinancialService"
| "HVACBusiness"
| "Dentist"
| "Physician"
| "Attorney" => analyze_service(content),
"Article" | "BlogPosting" | "NewsArticle" => analyze_article(content),
"FAQPage" => analyze_faq(content),
"Product" => analyze_product(content),
"WebSite" => analyze_website(content),
"WebPage" => analyze_webpage(content),
"BreadcrumbList" => analyze_breadcrumb(content),
_ => analyze_generic(content),
};
let mut present = Vec::new();
let mut missing = Vec::new();
for field in &expected {
if !content[field].is_null() {
present.push(field.to_string());
} else {
missing.push(field.to_string());
}
}
let total = present.len() + missing.len();
let pct = (present.len() * 100).checked_div(total).unwrap_or(0) as u32;
Some(SchemaDetail {
schema_type: schema_type.to_string(),
fields_present: present,
fields_missing: missing,
completeness_pct: pct,
extracted,
})
}
fn str_opt(v: &serde_json::Value, key: &str) -> Option<String> {
v[key].as_str().map(|s| s.to_string())
}
fn analyze_organization(v: &serde_json::Value) -> (Vec<&'static str>, SchemaExtracted) {
let expected = vec!["name", "url", "logo", "address", "telephone", "sameAs"];
let address = v["address"]["streetAddress"]
.as_str()
.map(|s| s.to_string())
.or_else(|| str_opt(v, "address"));
let extracted = SchemaExtracted::Organization {
name: str_opt(v, "name"),
url: str_opt(v, "url"),
logo: v["logo"]
.as_str()
.map(|s| s.to_string())
.or_else(|| str_opt(&v["logo"], "url")),
address,
phone: str_opt(v, "telephone"),
};
(expected, extracted)
}
fn analyze_local_business(v: &serde_json::Value) -> (Vec<&'static str>, SchemaExtracted) {
let expected = vec![
"name",
"address",
"telephone",
"openingHours",
"priceRange",
"geo",
"sameAs",
"url",
];
let addr = &v["address"];
let address = addr["streetAddress"]
.as_str()
.map(|s| s.to_string())
.or_else(|| str_opt(v, "address"));
let postal_code = str_opt(addr, "postalCode");
let locality = str_opt(addr, "addressLocality");
let country = str_opt(addr, "addressCountry")
.or_else(|| addr["addressCountry"]["@id"].as_str().map(String::from));
let opening_hours = v["openingHours"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|h| h.as_str().map(|s| s.to_string()))
.collect()
})
.or_else(|| v["openingHours"].as_str().map(|s| vec![s.to_string()]))
.unwrap_or_default();
let same_as = v["sameAs"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|s| s.as_str().map(String::from))
.collect()
})
.or_else(|| v["sameAs"].as_str().map(|s| vec![s.to_string()]))
.unwrap_or_default();
let area_served = v["areaServed"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|s| s.as_str().map(String::from))
.collect()
})
.or_else(|| v["areaServed"].as_str().map(|s| vec![s.to_string()]))
.unwrap_or_default();
let geo = &v["geo"];
let latitude = geo["latitude"]
.as_f64()
.or_else(|| geo["latitude"].as_str().and_then(|s| s.parse().ok()));
let longitude = geo["longitude"]
.as_f64()
.or_else(|| geo["longitude"].as_str().and_then(|s| s.parse().ok()));
let logo = v["logo"]
.as_str()
.map(String::from)
.or_else(|| str_opt(&v["logo"], "url"));
let image = v["image"]
.as_str()
.map(String::from)
.or_else(|| str_opt(&v["image"], "url"));
let aggregate_rating = v["aggregateRating"]["ratingValue"]
.as_str()
.map(String::from)
.or_else(|| {
v["aggregateRating"]["ratingValue"]
.as_f64()
.map(|r| format!("{:.1}", r))
});
let extracted = SchemaExtracted::LocalBusiness {
name: str_opt(v, "name"),
address,
postal_code,
locality,
country,
phone: str_opt(v, "telephone"),
url: str_opt(v, "url"),
opening_hours: Box::new(opening_hours),
price_range: str_opt(v, "priceRange"),
same_as: Box::new(same_as),
logo,
image,
area_served: Box::new(area_served),
latitude,
longitude,
aggregate_rating,
};
(expected, extracted)
}
fn analyze_article(v: &serde_json::Value) -> (Vec<&'static str>, SchemaExtracted) {
let expected = vec![
"headline",
"author",
"datePublished",
"dateModified",
"publisher",
"image",
];
let author = v["author"]["name"]
.as_str()
.map(|s| s.to_string())
.or_else(|| str_opt(v, "author"));
let publisher = v["publisher"]["name"]
.as_str()
.map(|s| s.to_string())
.or_else(|| str_opt(v, "publisher"));
let extracted = SchemaExtracted::Article {
headline: str_opt(v, "headline"),
author,
date_published: str_opt(v, "datePublished"),
date_modified: str_opt(v, "dateModified"),
publisher,
};
(expected, extracted)
}
fn analyze_faq(v: &serde_json::Value) -> (Vec<&'static str>, SchemaExtracted) {
let expected = vec!["mainEntity"];
let mut questions = Vec::new();
if let Some(entities) = v["mainEntity"].as_array() {
for entity in entities {
if let Some(q) = entity["name"].as_str() {
questions.push(q.to_string());
}
}
}
let extracted = SchemaExtracted::FAQPage {
question_count: questions.len(),
questions,
};
(expected, extracted)
}
fn analyze_product(v: &serde_json::Value) -> (Vec<&'static str>, SchemaExtracted) {
let expected = vec!["name", "offers", "image", "aggregateRating"];
let offers = &v["offers"];
let price = offers["price"]
.as_str()
.map(|s| s.to_string())
.or_else(|| offers["price"].as_f64().map(|p| format!("{:.2}", p)));
let currency = str_opt(offers, "priceCurrency");
let availability = str_opt(offers, "availability");
let rating = v["aggregateRating"]["ratingValue"]
.as_str()
.map(|s| s.to_string())
.or_else(|| {
v["aggregateRating"]["ratingValue"]
.as_f64()
.map(|r| format!("{:.1}", r))
});
let extracted = SchemaExtracted::Product {
name: str_opt(v, "name"),
price,
currency,
rating,
availability,
};
(expected, extracted)
}
fn analyze_website(v: &serde_json::Value) -> (Vec<&'static str>, SchemaExtracted) {
let expected = vec!["name", "url", "potentialAction"];
let has_search_action = v["potentialAction"]["@type"]
.as_str()
.map(|t| t == "SearchAction")
.unwrap_or(false);
let extracted = SchemaExtracted::WebSite {
name: str_opt(v, "name"),
url: str_opt(v, "url"),
has_search_action,
};
(expected, extracted)
}
fn analyze_webpage(v: &serde_json::Value) -> (Vec<&'static str>, SchemaExtracted) {
let expected = vec![
"name",
"description",
"url",
"image",
"inLanguage",
"author",
"publisher",
];
let author = v["author"]["name"]
.as_str()
.map(|s| s.to_string())
.or_else(|| str_opt(v, "author"));
let extracted = SchemaExtracted::WebPage {
name: str_opt(v, "name"),
url: str_opt(v, "url"),
author,
in_language: str_opt(v, "inLanguage"),
};
(expected, extracted)
}
fn analyze_service(v: &serde_json::Value) -> (Vec<&'static str>, SchemaExtracted) {
let expected = vec![
"name",
"description",
"url",
"telephone",
"address",
"serviceType",
"areaServed",
"priceRange",
];
let address = v["address"]["streetAddress"]
.as_str()
.map(|s| s.to_string())
.or_else(|| str_opt(v, "address"));
let area_served_count = v["areaServed"].as_array().map(|arr| arr.len()).unwrap_or(0);
let extracted = SchemaExtracted::Service {
name: str_opt(v, "name"),
address,
phone: str_opt(v, "telephone"),
price_range: str_opt(v, "priceRange"),
area_served_count,
};
(expected, extracted)
}
fn analyze_breadcrumb(v: &serde_json::Value) -> (Vec<&'static str>, SchemaExtracted) {
let expected = vec!["itemListElement"];
let item_count = v["itemListElement"]
.as_array()
.map(|arr| arr.len())
.unwrap_or(0);
let extracted = SchemaExtracted::BreadcrumbList { item_count };
(expected, extracted)
}
fn analyze_generic(v: &serde_json::Value) -> (Vec<&'static str>, SchemaExtracted) {
let mut key_fields = Vec::new();
if let Some(obj) = v.as_object() {
for (key, val) in obj {
if key.starts_with('@') {
continue;
}
let display = match val {
serde_json::Value::String(s) => {
if s.len() > 60 {
let b = s
.char_indices()
.take_while(|(i, _)| *i <= 57)
.last()
.map(|(i, _)| i)
.unwrap_or(0);
format!("{}...", &s[..b])
} else {
s.clone()
}
}
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Array(a) => format!("[{} Einträge]", a.len()),
serde_json::Value::Object(_) => "{...}".to_string(),
serde_json::Value::Null => continue,
};
key_fields.push((key.clone(), display));
if key_fields.len() >= 8 {
break;
}
}
}
(vec![], SchemaExtracted::Generic { key_fields })
}
fn build_signal_strength(seo: &SeoAnalysis) -> SeoSignalStrength {
let meta = build_meta_signals(seo);
let headings = build_heading_signals(seo);
let social = build_social_signals(seo);
let technical = build_technical_signals(seo);
let structured = build_structured_data_signals(seo);
let content = build_content_signals(seo);
let overall_pct = (meta.score_pct as f32 * 0.20
+ headings.score_pct as f32 * 0.15
+ social.score_pct as f32 * 0.15
+ technical.score_pct as f32 * 0.20
+ structured.score_pct as f32 * 0.15
+ content.score_pct as f32 * 0.15) as u32;
SeoSignalStrength {
categories: vec![meta, headings, social, technical, structured, content],
overall_pct,
}
}
fn category_score(checks: &[SignalCheck]) -> u32 {
if checks.is_empty() {
return 0;
}
let passed = checks.iter().filter(|c| c.passed).count();
(passed * 100 / checks.len()) as u32
}
fn check(label: &str, passed: bool, detail: Option<String>) -> SignalCheck {
SignalCheck {
label: label.to_string(),
passed,
detail,
}
}
fn build_meta_signals(seo: &SeoAnalysis) -> SignalCategory {
let title_ok = seo
.meta
.title
.as_ref()
.map(|t| t.len() >= 30 && t.len() <= 60)
.unwrap_or(false);
let desc_ok = seo
.meta
.description
.as_ref()
.map(|d| d.len() >= 120 && d.len() <= 160)
.unwrap_or(false);
let checks = vec![
check(
"Title vorhanden & Länge optimal",
title_ok,
seo.meta
.title
.as_ref()
.map(|t| format!("{} Zeichen", t.len())),
),
check(
"Description vorhanden & Länge optimal",
desc_ok,
seo.meta
.description
.as_ref()
.map(|d| format!("{} Zeichen", d.len())),
),
check(
"Viewport konfiguriert",
seo.meta.viewport.is_some(),
Some(
seo.meta
.viewport
.clone()
.unwrap_or_else(|| "fehlt".to_string()),
),
),
check(
"Charset definiert",
seo.meta.charset.is_some(),
Some(
seo.meta
.charset
.clone()
.unwrap_or_else(|| "fehlt".to_string()),
),
),
check(
"Sprache (lang) gesetzt",
seo.meta.lang.is_some(),
seo.meta.lang.clone(),
),
];
let score_pct = category_score(&checks);
SignalCategory {
name: "Meta-Tags".to_string(),
score_pct,
checks,
}
}
fn build_heading_signals(seo: &SeoAnalysis) -> SignalCategory {
let h1_ok = seo.headings.h1_count == 1;
let h1_not_empty = seo
.headings
.h1_text
.as_ref()
.map(|t| !t.trim().is_empty())
.unwrap_or(false);
let no_skipped = !seo
.headings
.issues
.iter()
.any(|i| i.issue_type == "skipped_level");
let no_issues = seo.headings.issues.is_empty();
let checks = vec![
check(
"Genau ein H1",
h1_ok,
Some(format!("{} H1-Tags gefunden", seo.headings.h1_count)),
),
check(
"H1-Text vorhanden",
h1_not_empty,
Some(
seo.headings
.h1_text
.clone()
.unwrap_or_else(|| "kein H1-Text".to_string()),
),
),
check(
"Keine übersprungenen Ebenen",
no_skipped,
Some(
if no_skipped {
"keine Lücken"
} else {
"Ebenen übersprungen"
}
.to_string(),
),
),
check(
"Logische Hierarchie",
no_issues,
Some(if no_issues {
"keine Probleme".to_string()
} else {
format!("{} Probleme", seo.headings.issues.len())
}),
),
];
let score_pct = category_score(&checks);
SignalCategory {
name: "Überschriften".to_string(),
score_pct,
checks,
}
}
fn build_social_signals(seo: &SeoAnalysis) -> SignalCategory {
let og_present = seo.social.open_graph.is_some();
let og_complete = seo
.social
.open_graph
.as_ref()
.map(|og| og.completeness() >= 80)
.unwrap_or(false);
let tw_present = seo.social.twitter_card.is_some();
let tw_complete = seo
.social
.twitter_card
.as_ref()
.map(|tc| tc.completeness() >= 75)
.unwrap_or(false);
let checks = vec![
check(
"OpenGraph-Tags vorhanden",
og_present,
Some(if og_present { "vorhanden" } else { "fehlt" }.to_string()),
),
check(
"OpenGraph ≥ 80% vollständig",
og_complete,
seo.social
.open_graph
.as_ref()
.map(|og| format!("{}%", og.completeness())),
),
check(
"Twitter Card vorhanden",
tw_present,
Some(if tw_present { "vorhanden" } else { "fehlt" }.to_string()),
),
check(
"Twitter Card ≥ 75% vollständig",
tw_complete,
seo.social
.twitter_card
.as_ref()
.map(|tc| format!("{}%", tc.completeness())),
),
];
let score_pct = category_score(&checks);
SignalCategory {
name: "Social Media".to_string(),
score_pct,
checks,
}
}
fn build_technical_signals(seo: &SeoAnalysis) -> SignalCategory {
let robots_ok = seo
.technical
.robots_meta
.as_ref()
.map(|r| !r.contains("noindex"))
.unwrap_or(true);
let checks = vec![
check(
"HTTPS",
seo.technical.https,
Some(
if seo.technical.https {
"aktiv"
} else {
"fehlt"
}
.to_string(),
),
),
check(
"Canonical URL",
seo.technical.has_canonical,
Some(
seo.technical
.canonical_url
.clone()
.unwrap_or_else(|| "fehlt".to_string()),
),
),
check(
"Sprache (lang)",
seo.technical.has_lang,
Some(
seo.technical
.lang
.clone()
.unwrap_or_else(|| "fehlt".to_string()),
),
),
check(
"Hreflang (Mehrsprachigkeit)",
true,
Some(if seo.technical.has_hreflang {
format!("{} Sprachen", seo.technical.hreflang.len())
} else {
"keine Hreflang-Tags".to_string()
}),
),
check(
"Robots erlaubt Indexierung",
robots_ok,
seo.technical.robots_meta.clone(),
),
];
let score_pct = category_score(&checks);
SignalCategory {
name: "Technisch".to_string(),
score_pct,
checks,
}
}
fn build_structured_data_signals(seo: &SeoAnalysis) -> SignalCategory {
let has_any = seo.structured_data.has_structured_data;
let has_rich = !seo.structured_data.rich_snippets_potential.is_empty();
let has_org = seo.structured_data.types.iter().any(|t| {
matches!(
t,
SchemaType::Organization | SchemaType::WebSite | SchemaType::LocalBusiness
)
});
let mut checks = vec![
check(
"Strukturierte Daten vorhanden",
has_any,
Some(if has_any {
format!("{} Schema(s)", seo.structured_data.json_ld.len())
} else {
"keine strukturierten Daten".to_string()
}),
),
check(
"Rich-Snippet-fähig",
has_rich,
Some(if has_rich {
seo.structured_data.rich_snippets_potential.join(", ")
} else {
"keine Rich-Snippet-Typen erkannt".to_string()
}),
),
check(
"Organization/WebSite Schema",
has_org,
Some(if has_org { "vorhanden" } else { "fehlt" }.to_string()),
),
];
let local_businesses: Vec<_> = seo
.structured_data
.json_ld
.iter()
.filter(|s| {
s.schema_type == "LocalBusiness"
|| s.schema_types
.iter()
.any(|t| t == "LocalBusiness" || t.ends_with("Business"))
})
.collect();
if !local_businesses.is_empty() {
let lb = &local_businesses[0].content;
let has_street = !lb["address"]["streetAddress"].is_null();
let has_postal = !lb["address"]["postalCode"].is_null();
let has_locality = !lb["address"]["addressLocality"].is_null();
let has_phone = !lb["telephone"].is_null();
let nap_complete = has_street && has_postal && has_locality && has_phone;
let nap_detail = if nap_complete {
None
} else {
let missing: Vec<&str> = [
(!has_street).then_some("streetAddress"),
(!has_postal).then_some("postalCode"),
(!has_locality).then_some("addressLocality"),
(!has_phone).then_some("telephone"),
]
.into_iter()
.flatten()
.collect();
Some(format!("Fehlt: {}", missing.join(", ")))
};
checks.push(check(
"LocalBusiness NAP vollständig",
nap_complete,
nap_detail,
));
let has_geo = !lb["geo"].is_null()
&& (!lb["geo"]["latitude"].is_null() || !lb["geo"]["longitude"].is_null());
checks.push(check(
"LocalBusiness Geo-Koordinaten",
has_geo,
if has_geo {
None
} else {
Some("geo.latitude/longitude fehlt".to_string())
},
));
let has_same_as = !lb["sameAs"].is_null();
checks.push(check(
"LocalBusiness sameAs (Autoritätslinks)",
has_same_as,
if has_same_as {
None
} else {
Some("sameAs fehlt — verhindert Knowledge-Panel".to_string())
},
));
let has_rating = !lb["aggregateRating"].is_null();
checks.push(check(
"LocalBusiness aggregateRating",
has_rating,
if has_rating {
None
} else {
Some("aggregateRating fehlt — keine Sterne im SERP".to_string())
},
));
if local_businesses.len() > 1 {
let first_name = lb["name"].as_str().unwrap_or("");
let consistent = local_businesses
.iter()
.all(|s| s.content["name"].as_str().unwrap_or("") == first_name);
checks.push(check(
"LocalBusiness-Schemas konsistent",
consistent,
if consistent {
Some(format!(
"{} Schemas, Name einheitlich",
local_businesses.len()
))
} else {
Some(format!(
"{} Schemas mit widersprüchlichem Namen",
local_businesses.len()
))
},
));
}
}
if !seo.structured_data.schema_issues.is_empty() {
let mut by_type: std::collections::BTreeMap<&str, Vec<&str>> =
std::collections::BTreeMap::new();
for issue in &seo.structured_data.schema_issues {
by_type
.entry(issue.schema_type.as_str())
.or_default()
.push(issue.message.split('"').nth(1).unwrap_or("?"));
}
for (type_name, fields) in &by_type {
checks.push(check(
&format!("{} Pflichtfelder vollständig", type_name),
false,
Some(format!("Fehlt: {}", fields.join(", "))),
));
}
}
let score_pct = category_score(&checks);
SignalCategory {
name: "Strukturierte Daten".to_string(),
score_pct,
checks,
}
}
fn build_content_signals(seo: &SeoAnalysis) -> SignalCategory {
let wc = seo.technical.word_count;
let il = seo.technical.internal_links;
let link_density = if wc > 0 { il as f32 / wc as f32 } else { 0.0 };
let checks = vec![
check(
"Inhaltsstärke (≥ 300 Wörter, Richtwert)",
wc >= 300,
Some(format!("{} Wörter", wc)),
),
check(
"Interne Verlinkung (≥ 3 Links)",
il >= 3,
Some(format!("{} interne Links", il)),
),
check(
"Linkdichte > 0,5%",
link_density > 0.005,
Some(format!("{:.2}%", link_density * 100.0)),
),
];
let score_pct = category_score(&checks);
SignalCategory {
name: "Inhaltsqualität".to_string(),
score_pct,
checks,
}
}
fn count_techniques(seo: &SeoAnalysis) -> u32 {
let mut count = 0u32;
if seo
.meta
.title
.as_ref()
.map(|t| t.len() >= 30 && t.len() <= 60)
.unwrap_or(false)
{
count += 1;
}
if seo
.meta
.description
.as_ref()
.map(|d| d.len() >= 120 && d.len() <= 160)
.unwrap_or(false)
{
count += 1;
}
if seo
.social
.open_graph
.as_ref()
.map(|og| og.completeness() >= 80)
.unwrap_or(false)
{
count += 1;
}
if seo
.social
.twitter_card
.as_ref()
.map(|tc| tc.completeness() >= 75)
.unwrap_or(false)
{
count += 1;
}
if seo.structured_data.has_structured_data {
count += 1;
}
if seo.technical.https {
count += 1;
}
if seo.technical.has_canonical {
count += 1;
}
if seo.technical.has_lang {
count += 1;
}
if seo.headings.h1_count == 1 && seo.headings.issues.is_empty() {
count += 1;
}
if seo.technical.word_count >= 300 {
count += 1;
}
if seo.technical.internal_links >= 3 {
count += 1;
}
if !seo.structured_data.rich_snippets_potential.is_empty() {
count += 1;
}
if seo.technical.has_hreflang {
count += 1;
}
count
}
#[cfg(test)]
mod tests {
use super::*;
use crate::seo::headings::HeadingInfo;
use crate::seo::*;
fn minimal_seo() -> SeoAnalysis {
SeoAnalysis {
meta: MetaTags::default(),
meta_issues: vec![],
headings: HeadingStructure {
h1_count: 0,
h1_text: None,
headings: vec![],
issues: vec![],
total_count: 0,
},
social: SocialTags {
open_graph: None,
twitter_card: None,
completeness: 0,
},
technical: technical::TechnicalSeo::default(),
structured_data: StructuredData::default(),
score: 0,
content_profile: None,
robots: None,
page_health: None,
serp: None,
image_efficiency: None,
}
}
#[test]
fn test_maturity_basic_with_no_data() {
let seo = minimal_seo();
let profile = build_content_profile(&seo);
assert_eq!(profile.maturity, SeoMaturityLevel::Basic);
assert_eq!(profile.maturity_techniques, 0);
}
#[test]
fn test_content_identity_from_meta() {
let mut seo = minimal_seo();
seo.meta.title = Some("Casoon - CSS Framework & Design System".to_string());
seo.meta.description = Some("Casoon bietet ein modernes CSS Framework.".to_string());
seo.technical.lang = Some("de".to_string());
let profile = build_content_profile(&seo);
assert!(profile.content_identity.summary.contains("Casoon"));
assert_eq!(profile.content_identity.language, Some("de".to_string()));
assert_eq!(profile.content_identity.content_type, "Website");
}
#[test]
fn test_schema_inventory_faq() {
let mut seo = minimal_seo();
let faq_content = serde_json::json!({
"@type": "FAQPage",
"mainEntity": [
{"@type": "Question", "name": "Was ist Casoon?", "acceptedAnswer": {"text": "Ein Framework."}},
{"@type": "Question", "name": "Wie installiere ich es?", "acceptedAnswer": {"text": "Via npm."}},
]
});
seo.structured_data.json_ld.push(schema::JsonLdSchema {
schema_type: "FAQPage".to_string(),
schema_types: vec!["FAQPage".to_string()],
content: faq_content,
is_valid: true,
});
seo.structured_data.types.push(SchemaType::FAQPage);
seo.structured_data.has_structured_data = true;
let profile = build_content_profile(&seo);
assert_eq!(profile.schema_inventory.total_count, 1);
let faq = &profile.schema_inventory.schemas[0];
assert_eq!(faq.schema_type, "FAQPage");
if let SchemaExtracted::FAQPage {
question_count,
questions,
} = &faq.extracted
{
assert_eq!(*question_count, 2);
assert_eq!(questions[0], "Was ist Casoon?");
} else {
panic!("Expected FAQPage extraction");
}
}
#[test]
fn test_signal_strength_perfect() {
let mut seo = minimal_seo();
seo.meta.title = Some("Ein optimaler Titel der genau passt!!!!".to_string()); seo.meta.description = Some("Eine perfekte Beschreibung die zwischen 120 und 160 Zeichen liegt und alles sagt was man wissen muss über den Inhalt.".to_string());
seo.meta.keywords = Some("test".to_string());
seo.meta.viewport = Some("width=device-width".to_string());
seo.meta.charset = Some("UTF-8".to_string());
seo.meta.lang = Some("de".to_string());
seo.headings.h1_count = 1;
seo.headings.h1_text = Some("Hauptüberschrift".to_string());
seo.social.open_graph = Some(OpenGraph {
title: Some("T".into()),
description: Some("D".into()),
image: Some("I".into()),
url: Some("U".into()),
og_type: Some("website".into()),
site_name: Some("S".into()),
locale: Some("L".into()),
});
seo.social.twitter_card = Some(TwitterCard {
card: Some("summary".into()),
title: Some("T".into()),
description: Some("D".into()),
image: Some("I".into()),
site: Some("S".into()),
creator: Some("C".into()),
});
seo.technical.https = true;
seo.technical.has_canonical = true;
seo.technical.has_lang = true;
seo.technical.word_count = 500;
seo.technical.internal_links = 10;
seo.structured_data.has_structured_data = true;
seo.structured_data.types.push(SchemaType::Organization);
seo.structured_data
.rich_snippets_potential
.push("Test".to_string());
let profile = build_content_profile(&seo);
for cat in &profile.signal_strength.categories {
assert!(cat.score_pct >= 75, "{} only {}%", cat.name, cat.score_pct);
}
assert!(profile.signal_strength.overall_pct >= 80);
assert!(matches!(
profile.maturity,
SeoMaturityLevel::Advanced | SeoMaturityLevel::Professional
));
}
#[test]
fn test_schema_inventory_handles_webpage_and_professional_service() {
let mut seo = minimal_seo();
let webpage = serde_json::json!({
"@context": "https://schema.org",
"@type": "WebPage",
"name": "CASOON Startseite",
"description": "Webentwicklung und digitale Systeme.",
"url": "https://www.casoon.de/",
"image": "https://www.casoon.de/og.webp",
"inLanguage": "de-DE",
"author": {"@type": "Person", "name": "Jörn Seidel"},
"publisher": {"@type": "Organization", "name": "CASOON"}
});
let service = serde_json::json!({
"@context": "https://schema.org",
"@type": ["ProfessionalService", "LocalBusiness"],
"name": "CASOON",
"description": "Webentwicklung und digitale Systeme.",
"url": "https://www.casoon.de/",
"telephone": "+49 381 448840",
"address": {"@type": "PostalAddress", "streetAddress": "Zur Häusler-Reihe 10"},
"serviceType": ["Webentwicklung", "SEO"],
"areaServed": [{"@type": "City", "name": "Rostock"}],
"priceRange": "$$"
});
seo.structured_data.json_ld.push(schema::JsonLdSchema {
schema_type: "WebPage".to_string(),
schema_types: vec!["WebPage".to_string()],
content: webpage,
is_valid: true,
});
seo.structured_data.json_ld.push(schema::JsonLdSchema {
schema_type: "ProfessionalService".to_string(),
schema_types: vec![
"ProfessionalService".to_string(),
"LocalBusiness".to_string(),
],
content: service,
is_valid: true,
});
seo.structured_data.types.push(SchemaType::WebPage);
seo.structured_data
.types
.push(SchemaType::Other("ProfessionalService".to_string()));
seo.structured_data.types.push(SchemaType::LocalBusiness);
seo.structured_data.has_structured_data = true;
let profile = build_content_profile(&seo);
assert_eq!(profile.schema_inventory.total_count, 2);
let webpage_detail = profile
.schema_inventory
.schemas
.iter()
.find(|s| s.schema_type == "WebPage")
.expect("WebPage schema missing");
assert!(webpage_detail.completeness_pct >= 85);
let service_detail = profile
.schema_inventory
.schemas
.iter()
.find(|s| s.schema_type == "ProfessionalService")
.expect("ProfessionalService schema missing");
assert!(service_detail.completeness_pct >= 75);
}
#[test]
fn test_maturity_levels() {
assert_eq!(SeoMaturityLevel::from_count(0), SeoMaturityLevel::Basic);
assert_eq!(SeoMaturityLevel::from_count(3), SeoMaturityLevel::Basic);
assert_eq!(SeoMaturityLevel::from_count(4), SeoMaturityLevel::Standard);
assert_eq!(SeoMaturityLevel::from_count(7), SeoMaturityLevel::Advanced);
assert_eq!(
SeoMaturityLevel::from_count(10),
SeoMaturityLevel::Professional
);
}
#[test]
fn test_classify_editorial_page() {
let mut seo = minimal_seo();
seo.technical.word_count = 1800;
seo.headings.headings = vec![
HeadingInfo {
level: 1,
text: "Titel".into(),
length: 5,
is_question: false,
in_faq_context: false,
},
HeadingInfo {
level: 2,
text: "A".into(),
length: 1,
is_question: false,
in_faq_context: false,
},
HeadingInfo {
level: 2,
text: "B".into(),
length: 1,
is_question: false,
in_faq_context: false,
},
HeadingInfo {
level: 3,
text: "C".into(),
length: 1,
is_question: false,
in_faq_context: false,
},
];
seo.headings.total_count = seo.headings.headings.len();
seo.structured_data.types.push(SchemaType::Article);
let profile = build_content_profile(&seo);
assert_eq!(
profile.page_classification.primary_type,
PageType::Editorial
);
assert!(profile
.page_classification
.attributes
.contains(&"textstark".to_string()));
}
#[test]
fn test_classify_thin_page() {
let mut seo = minimal_seo();
seo.technical.word_count = 120;
let profile = build_content_profile(&seo);
assert_eq!(
profile.page_classification.primary_type,
PageType::ThinContent
);
assert!(profile.page_classification.content_depth_score < 40);
}
}