use chromiumoxide::Page;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::error::{AuditError, Result};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StructuredData {
pub json_ld: Vec<JsonLdSchema>,
pub types: Vec<SchemaType>,
pub has_structured_data: bool,
pub rich_snippets_potential: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub schema_issues: Vec<SchemaIssue>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaIssue {
pub schema_type: String,
pub issue_type: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonLdSchema {
pub schema_type: String,
#[serde(default)]
pub schema_types: Vec<String>,
pub content: serde_json::Value,
pub is_valid: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SchemaType {
Organization,
LocalBusiness,
Person,
Article,
BlogPosting,
NewsArticle,
Product,
Offer,
Event,
Recipe,
VideoObject,
WebPage,
WebSite,
BreadcrumbList,
FAQPage,
HowTo,
Review,
AggregateRating,
Other(String),
}
impl std::str::FromStr for SchemaType {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
Ok(match s {
"Organization" => SchemaType::Organization,
"LocalBusiness" => SchemaType::LocalBusiness,
"Person" => SchemaType::Person,
"Article" => SchemaType::Article,
"BlogPosting" => SchemaType::BlogPosting,
"NewsArticle" => SchemaType::NewsArticle,
"Product" => SchemaType::Product,
"Offer" => SchemaType::Offer,
"Event" => SchemaType::Event,
"Recipe" => SchemaType::Recipe,
"VideoObject" => SchemaType::VideoObject,
"WebPage" => SchemaType::WebPage,
"WebSite" => SchemaType::WebSite,
"BreadcrumbList" => SchemaType::BreadcrumbList,
"FAQPage" => SchemaType::FAQPage,
"HowTo" => SchemaType::HowTo,
"Review" => SchemaType::Review,
"AggregateRating" => SchemaType::AggregateRating,
other => SchemaType::Other(other.to_string()),
})
}
}
impl SchemaType {
pub fn as_str(&self) -> &str {
match self {
Self::Organization => "Organization",
Self::LocalBusiness => "LocalBusiness",
Self::Person => "Person",
Self::Article => "Article",
Self::BlogPosting => "BlogPosting",
Self::NewsArticle => "NewsArticle",
Self::Product => "Product",
Self::Offer => "Offer",
Self::Event => "Event",
Self::Recipe => "Recipe",
Self::VideoObject => "VideoObject",
Self::WebPage => "WebPage",
Self::WebSite => "WebSite",
Self::BreadcrumbList => "BreadcrumbList",
Self::FAQPage => "FAQPage",
Self::HowTo => "HowTo",
Self::Review => "Review",
Self::AggregateRating => "AggregateRating",
Self::Other(s) => s,
}
}
pub fn is_organization_like(&self) -> bool {
matches!(self, Self::Organization | Self::LocalBusiness)
|| matches!(
self.as_str(),
"Airline"
| "Consortium"
| "Corporation"
| "EducationalOrganization"
| "FundingScheme"
| "GovernmentOrganization"
| "LibrarySystem"
| "LocalBusiness"
| "MedicalOrganization"
| "NGO"
| "NewsMediaOrganization"
| "OnlineBusiness"
| "PerformingGroup"
| "Project"
| "ResearchOrganization"
| "SearchRescueOrganization"
| "SportsOrganization"
| "WorkersUnion"
| "LegalService"
| "ProfessionalService"
)
}
pub fn rich_snippet_type(&self) -> Option<&'static str> {
match self {
SchemaType::Article | SchemaType::BlogPosting | SchemaType::NewsArticle => {
Some("Article Rich Snippet")
}
SchemaType::Product => Some("Product Rich Snippet"),
SchemaType::Recipe => Some("Recipe Rich Snippet"),
SchemaType::Event => Some("Event Rich Snippet"),
SchemaType::FAQPage => Some("FAQ Rich Snippet"),
SchemaType::HowTo => Some("How-To Rich Snippet"),
SchemaType::Review | SchemaType::AggregateRating => Some("Review Rich Snippet"),
SchemaType::BreadcrumbList => Some("Breadcrumb Rich Snippet"),
SchemaType::VideoObject => Some("Video Rich Snippet"),
SchemaType::LocalBusiness => Some("Local Business Rich Snippet"),
_ => None,
}
}
}
pub async fn detect_structured_data(page: &Page) -> Result<StructuredData> {
info!("Detecting structured data...");
let js_code = r#"
(() => {
const result = { jsonLd: [], microdata: false, rdfa: false };
// Find JSON-LD scripts
document.querySelectorAll('script[type="application/ld+json"]').forEach(script => {
try {
const content = JSON.parse(script.textContent);
result.jsonLd.push(content);
} catch (e) {
result.jsonLd.push({ error: 'Invalid JSON', raw: script.textContent.substring(0, 200) });
}
});
// Check for microdata
result.microdata = document.querySelectorAll('[itemscope]').length > 0;
// Check for RDFa
result.rdfa = document.querySelectorAll('[typeof], [property]').length > 0;
return JSON.stringify(result);
})()
"#;
let js_result = page
.evaluate(js_code)
.await
.map_err(|e| AuditError::CdpError(format!("Structured data detection failed: {}", e)))?;
let json_str = js_result.value().and_then(|v| v.as_str()).unwrap_or("{}");
let parsed: serde_json::Value = serde_json::from_str(json_str).unwrap_or_default();
let mut json_ld = Vec::new();
let mut types = Vec::new();
let mut rich_snippets_potential = Vec::new();
if let Some(schemas) = parsed["jsonLd"].as_array() {
for schema in schemas {
let is_valid = schema.get("error").is_none();
let schema_types = extract_types(schema);
for type_str in &schema_types {
let schema_type: SchemaType = type_str
.parse()
.unwrap_or(SchemaType::Other(type_str.to_string()));
if let Some(rich_snippet) = schema_type.rich_snippet_type() {
if !rich_snippets_potential.contains(&rich_snippet.to_string()) {
rich_snippets_potential.push(rich_snippet.to_string());
}
}
if !types.contains(&schema_type) {
types.push(schema_type);
}
}
json_ld.push(JsonLdSchema {
schema_type: schema_types.first().cloned().unwrap_or_default(),
schema_types,
content: schema.clone(),
is_valid,
});
if is_valid {
if let Some(graph) = schema["@graph"].as_array() {
for graph_item in graph {
let item_types: Vec<String> = if let Some(s) = graph_item["@type"].as_str()
{
vec![s.to_string()]
} else if let Some(arr) = graph_item["@type"].as_array() {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
} else {
vec![]
};
if item_types.is_empty() {
continue;
}
json_ld.push(JsonLdSchema {
schema_type: item_types.first().cloned().unwrap_or_default(),
schema_types: item_types,
content: graph_item.clone(),
is_valid: true,
});
}
}
}
}
}
let has_structured_data = !json_ld.is_empty()
|| parsed["microdata"].as_bool().unwrap_or(false)
|| parsed["rdfa"].as_bool().unwrap_or(false);
info!(
"Structured data: {} JSON-LD schemas, {} types, {} rich snippet opportunities",
json_ld.len(),
types.len(),
rich_snippets_potential.len()
);
let schema_issues = json_ld
.iter()
.flat_map(validate_schema_properties)
.collect();
Ok(StructuredData {
json_ld,
types,
has_structured_data,
rich_snippets_potential,
schema_issues,
})
}
fn validate_schema_properties(schema: &JsonLdSchema) -> Vec<SchemaIssue> {
if !schema.is_valid {
return vec![];
}
let required: &[(&str, &[&str])] = &[
("Article", &["headline", "image", "author", "datePublished"]),
(
"BlogPosting",
&["headline", "image", "author", "datePublished"],
),
(
"NewsArticle",
&["headline", "image", "author", "datePublished"],
),
("Product", &["name", "image"]),
("Event", &["name", "startDate"]),
(
"VideoObject",
&["name", "description", "thumbnailUrl", "uploadDate"],
),
("Recipe", &["name", "image"]),
("HowTo", &["name"]),
("Review", &["author"]),
];
let types_to_check: Vec<String> = if schema.schema_types.is_empty() {
schema
.schema_type
.split('/')
.next_back()
.map(|s| vec![s.to_string()])
.unwrap_or_default()
} else {
schema
.schema_types
.iter()
.map(|t| t.split('/').next_back().unwrap_or(t).to_string())
.collect()
};
let mut issues = Vec::new();
for (type_name, props) in required {
if types_to_check.iter().any(|t| t == type_name) {
for prop in *props {
if schema.content[prop].is_null() {
issues.push(SchemaIssue {
schema_type: type_name.to_string(),
issue_type: format!("schema_missing_{}", prop),
message: format!(
"{}: Pflichtfeld \"{}\" fehlt im JSON-LD",
type_name, prop
),
});
}
}
}
}
issues
}
fn extract_types(schema: &serde_json::Value) -> Vec<String> {
let mut types = Vec::new();
if let Some(type_str) = schema["@type"].as_str() {
types.push(type_str.to_string());
} else if let Some(type_arr) = schema["@type"].as_array() {
for t in type_arr {
if let Some(s) = t.as_str() {
types.push(s.to_string());
}
}
}
if let Some(graph) = schema["@graph"].as_array() {
for item in graph {
types.extend(extract_types(item));
}
}
types
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_schema_type_from_str() {
assert_eq!(
"Article".parse::<SchemaType>().unwrap(),
SchemaType::Article
);
assert_eq!(
"Product".parse::<SchemaType>().unwrap(),
SchemaType::Product
);
assert!(matches!(
"CustomType".parse::<SchemaType>().unwrap(),
SchemaType::Other(_)
));
}
#[test]
fn test_rich_snippet_type() {
assert_eq!(
SchemaType::Article.rich_snippet_type(),
Some("Article Rich Snippet")
);
assert_eq!(
SchemaType::Product.rich_snippet_type(),
Some("Product Rich Snippet")
);
assert_eq!(SchemaType::Organization.rich_snippet_type(), None);
}
#[test]
fn organization_like_accepts_schema_subtypes() {
assert!(SchemaType::Organization.is_organization_like());
assert!(SchemaType::LocalBusiness.is_organization_like());
assert!(SchemaType::Other("LegalService".into()).is_organization_like());
assert!(SchemaType::Other("MedicalOrganization".into()).is_organization_like());
assert!(!SchemaType::Person.is_organization_like());
assert!(!SchemaType::WebSite.is_organization_like());
}
}