mod headings;
pub mod image_efficiency;
pub mod interpretation;
mod meta;
pub mod module;
pub mod page_health;
pub mod profile;
pub mod robots;
pub mod schema;
pub mod serp;
mod social;
pub mod technical;
pub mod topics;
pub use module::SeoModule;
pub use headings::{analyze_heading_structure, HeadingIssue, HeadingStructure};
pub use image_efficiency::{analyze_image_efficiency, ImageEfficiencyAnalysis, OversizedImage};
pub use meta::{extract_meta_tags, MetaTags, MetaValidation};
pub use page_health::{
analyze_page_health, HtmlValidationIssue, PageHealthAnalysis, PageHealthIssue, WwwConsolidation,
};
pub use profile::{build_content_profile, SeoContentProfile};
pub use robots::{audit_robots_txt, infer_robots_policy, BotClass, RobotsAudit, RobotsGroup};
pub use schema::{detect_structured_data, SchemaType, StructuredData};
pub use serp::{build_serp_analysis, SerpAnalysis, SerpSignal, SerpSignalStatus};
pub use social::{extract_social_tags, OpenGraph, SocialTags, TwitterCard};
pub use technical::{analyze_technical_seo, collect_technical_issues, TechnicalSeo};
pub use topics::{
average_page_semantic_score, derive_domain_topics, derive_topic_overlap_pairs,
extract_page_topics,
};
use chromiumoxide::Page;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::error::Result;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SeoAnalysis {
pub meta: MetaTags,
pub meta_issues: Vec<MetaValidation>,
pub headings: HeadingStructure,
pub social: SocialTags,
pub technical: TechnicalSeo,
pub structured_data: StructuredData,
pub score: u32,
pub content_profile: Option<SeoContentProfile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub robots: Option<RobotsAudit>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page_health: Option<PageHealthAnalysis>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub serp: Option<SerpAnalysis>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_efficiency: Option<ImageEfficiencyAnalysis>,
}
pub async fn analyze_seo(page: &Page, url: &str) -> Result<SeoAnalysis> {
let meta = extract_meta_tags(page).await?;
let meta_issues = meta.validate();
let headings = analyze_heading_structure(page).await?;
let social = extract_social_tags(page).await?;
let technical = analyze_technical_seo(page, url).await?;
let structured_data = detect_structured_data(page).await?;
let is_noindex = technical
.robots_meta
.as_deref()
.map(|r| r.to_lowercase().contains("noindex"))
.unwrap_or(false);
let robots =
Some(audit_robots_txt(url, technical.canonical_url.as_deref(), is_noindex, "en").await);
let page_health = match analyze_page_health(page, url).await {
Ok(ph) => Some(ph),
Err(e) => {
warn!("Page health analysis failed: {}", e);
None
}
};
let image_efficiency = match analyze_image_efficiency(page).await {
Ok(ie) => Some(ie),
Err(e) => {
warn!("Image efficiency analysis failed: {}", e);
None
}
};
let score = calculate_seo_score(
&meta,
&meta_issues,
&headings,
&social,
&technical,
image_efficiency.as_ref(),
);
let mut analysis = SeoAnalysis {
meta,
meta_issues,
headings,
social,
technical,
structured_data,
score,
content_profile: None,
robots,
page_health,
serp: None,
image_efficiency,
};
analysis.content_profile = Some(build_content_profile(&analysis, "en"));
analysis.serp = Some(build_serp_analysis(&analysis, url, "en"));
Ok(analysis)
}
fn calculate_seo_score(
meta: &MetaTags,
meta_issues: &[MetaValidation],
headings: &HeadingStructure,
social: &SocialTags,
technical: &TechnicalSeo,
image_efficiency: Option<&ImageEfficiencyAnalysis>,
) -> u32 {
let mut score = 100u32;
let meta_penalty: u32 = meta_issues
.iter()
.map(|issue| match issue.severity {
crate::taxonomy::Severity::Critical => 15,
crate::taxonomy::Severity::High => 10,
crate::taxonomy::Severity::Medium => 5,
crate::taxonomy::Severity::Low => 2,
})
.sum::<u32>()
.min(20);
score = score.saturating_sub(meta_penalty);
if headings.h1_count == 0 {
score = score.saturating_sub(15);
} else if headings.h1_count > 1 {
score = score.saturating_sub(5);
}
if !headings.issues.is_empty() {
let heading_penalty = (headings.issues.len() as u32 * 3).min(15);
score = score.saturating_sub(heading_penalty);
}
if social.open_graph.is_none() {
score = score.saturating_sub(5);
}
if social.twitter_card.is_none() {
score = score.saturating_sub(5);
}
if !technical.https {
score = score.saturating_sub(10);
}
if !technical.has_canonical {
score = score.saturating_sub(5);
}
if !technical.has_lang {
score = score.saturating_sub(3);
}
if let Some(ie) = image_efficiency {
let ie_penalty = (100 - ie.score).min(15);
score = score.saturating_sub(ie_penalty);
}
let baseline_signals = [
meta.title.as_ref().is_some_and(|t| !t.trim().is_empty()),
meta.description
.as_ref()
.is_some_and(|d| !d.trim().is_empty()),
technical.https,
technical.has_canonical,
technical.has_lang,
social.open_graph.is_some(),
];
let floor = baseline_signals.iter().filter(|present| **present).count() as u32 * 10;
score.max(floor).min(100)
}