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>,
}
#[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 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,
});
}
}
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()
);
Ok(StructuredData {
json_ld,
types,
has_structured_data,
rich_snippets_potential,
})
}
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);
}
}