use chromiumoxide::Page;
use html5ever::{parse_document, tendril::TendrilSink};
use markup5ever_rcdom::RcDom;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use crate::error::{AuditError, Result};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PageHealthAnalysis {
pub soft_404_status: Option<u16>,
pub is_soft_404: bool,
pub has_meta_refresh: bool,
pub meta_refresh_content: Option<String>,
pub frame_count: u32,
pub iframe_count: u32,
pub cross_origin_iframe_count: u32,
pub url_length: usize,
pub url_has_query_params: bool,
pub url_is_dynamic: bool,
pub url_path_depth: usize,
pub url_is_too_long: bool,
pub url_is_too_deep: bool,
pub own_redirect_detected: bool,
pub own_final_url: Option<String>,
pub www_consolidation: Option<WwwConsolidation>,
pub duplicate_id_count: u32,
pub images_without_alt: u32,
pub tables_without_headers: u32,
pub empty_headings: u32,
pub nested_interactive_count: u32,
pub html_issues: Vec<HtmlValidationIssue>,
pub html_validator_status: String,
pub html_validator_detail: Option<String>,
pub issues: Vec<PageHealthIssue>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WwwConsolidation {
pub www_status: Option<u16>,
pub non_www_status: Option<u16>,
pub www_redirects_to_non_www: bool,
pub non_www_redirects_to_www: bool,
pub canonical_variant: String,
pub is_consolidated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HtmlValidationIssue {
pub check: String,
pub count: u32,
pub severity: String,
pub detail: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageHealthIssue {
pub issue_type: String,
pub message: String,
pub severity: String,
}
pub async fn analyze_page_health(page: &Page, url: &str) -> Result<PageHealthAnalysis> {
let mut analysis = PageHealthAnalysis {
html_validator_status: "skipped".to_string(),
..Default::default()
};
analyze_url(url, &mut analysis);
if let Err(e) = run_dom_inspection(page, url, &mut analysis).await {
warn!("Page health DOM inspection failed: {}", e);
}
run_http_probes(url, &mut analysis).await;
if let Err(e) = run_w3c_html_validation(page, url, &mut analysis).await {
analysis.html_validator_status = "failed".to_string();
analysis.html_validator_detail = Some(e.to_string());
warn!("W3C HTML validation failed: {}", e);
}
analysis.issues = collect_issues(&analysis);
Ok(analysis)
}
fn analyze_url(url: &str, a: &mut PageHealthAnalysis) {
a.url_length = url.len();
a.url_is_too_long = url.len() > 115;
if let Ok(parsed) = url::Url::parse(url) {
a.url_has_query_params = parsed.query().is_some();
a.url_is_dynamic = a.url_has_query_params;
let depth = parsed
.path_segments()
.map(|segs| segs.filter(|s| !s.is_empty()).count())
.unwrap_or(0);
a.url_path_depth = depth;
a.url_is_too_deep = depth > 5;
}
}
async fn run_dom_inspection(page: &Page, url: &str, a: &mut PageHealthAnalysis) -> Result<()> {
let js = r#"
(() => {
const r = {};
const host = window.location.host;
// Final URL (redirect detection)
r.finalUrl = window.location.href;
// Meta-refresh
const mr = document.querySelector('meta[http-equiv="refresh"]');
r.hasMetaRefresh = !!mr;
r.metaRefreshContent = mr ? mr.getAttribute('content') : null;
// Frames
r.frameCount = document.querySelectorAll('frame, frameset').length;
r.iframeCount = document.querySelectorAll('iframe').length;
r.crossOriginIframeCount = Array.from(document.querySelectorAll('iframe[src]'))
.filter(f => {
try { return new URL(f.src).host !== host; } catch(e) { return false; }
}).length;
// Duplicate IDs
const allIds = Array.from(document.querySelectorAll('[id]')).map(el => el.id);
const idCounts = {};
allIds.forEach(id => { idCounts[id] = (idCounts[id] || 0) + 1; });
const dupIds = Object.entries(idCounts).filter(([_, c]) => c > 1);
r.duplicateIdCount = dupIds.length;
r.duplicateIdSamples = dupIds.slice(0, 5).map(([id]) => id);
// Images without alt
r.imagesWithoutAlt = document.querySelectorAll('img:not([alt])').length;
// Tables without headers
r.tablesWithoutHeaders = Array.from(document.querySelectorAll('table'))
.filter(t => !t.querySelector('th') && !t.querySelector('caption')).length;
// Empty headings
r.emptyHeadings = document.querySelectorAll(
'h1:empty,h2:empty,h3:empty,h4:empty,h5:empty,h6:empty'
).length;
// Nested interactive elements
r.nestedInteractive = document.querySelectorAll('button button, a a').length;
return JSON.stringify(r);
})()
"#;
let result = page
.evaluate(js)
.await
.map_err(|e| AuditError::CdpError(format!("Page health JS failed: {}", e)))?;
let json_str = result.value().and_then(|v| v.as_str()).unwrap_or("{}");
let parsed: serde_json::Value = serde_json::from_str(json_str).unwrap_or_default();
if let Some(final_url) = parsed["finalUrl"].as_str() {
let canonical = |s: &str| s.trim_end_matches('/').to_string();
if canonical(final_url) != canonical(url) {
a.own_redirect_detected = true;
a.own_final_url = Some(final_url.to_string());
}
}
a.has_meta_refresh = parsed["hasMetaRefresh"].as_bool().unwrap_or(false);
a.meta_refresh_content = parsed["metaRefreshContent"].as_str().map(String::from);
a.frame_count = parsed["frameCount"].as_u64().unwrap_or(0) as u32;
a.iframe_count = parsed["iframeCount"].as_u64().unwrap_or(0) as u32;
a.cross_origin_iframe_count = parsed["crossOriginIframeCount"].as_u64().unwrap_or(0) as u32;
a.duplicate_id_count = parsed["duplicateIdCount"].as_u64().unwrap_or(0) as u32;
a.images_without_alt = parsed["imagesWithoutAlt"].as_u64().unwrap_or(0) as u32;
a.tables_without_headers = parsed["tablesWithoutHeaders"].as_u64().unwrap_or(0) as u32;
a.empty_headings = parsed["emptyHeadings"].as_u64().unwrap_or(0) as u32;
a.nested_interactive_count = parsed["nestedInteractive"].as_u64().unwrap_or(0) as u32;
a.html_issues = build_html_issues(a, &parsed);
Ok(())
}
fn build_html_issues(
a: &PageHealthAnalysis,
parsed: &serde_json::Value,
) -> Vec<HtmlValidationIssue> {
let mut html_issues = Vec::new();
if a.duplicate_id_count > 0 {
let samples = parsed["duplicateIdSamples"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
html_issues.push(HtmlValidationIssue {
check: "Doppelte IDs".to_string(),
count: a.duplicate_id_count,
severity: "medium".to_string(),
detail: if samples.is_empty() {
format!("{} gefunden", a.duplicate_id_count)
} else {
format!("{} (z.B. {})", a.duplicate_id_count, samples)
},
});
}
if a.images_without_alt > 0 {
html_issues.push(HtmlValidationIssue {
check: "Bilder ohne alt-Attribut".to_string(),
count: a.images_without_alt,
severity: "high".to_string(),
detail: format!("{} <img> ohne alt", a.images_without_alt),
});
}
if a.tables_without_headers > 0 {
html_issues.push(HtmlValidationIssue {
check: "Tabellen ohne Kopfzeile".to_string(),
count: a.tables_without_headers,
severity: "medium".to_string(),
detail: format!(
"{} <table> ohne <th> oder <caption>",
a.tables_without_headers
),
});
}
if a.empty_headings > 0 {
html_issues.push(HtmlValidationIssue {
check: "Leere Überschriften".to_string(),
count: a.empty_headings,
severity: "medium".to_string(),
detail: format!("{} leere h1–h6 Elemente", a.empty_headings),
});
}
if a.nested_interactive_count > 0 {
html_issues.push(HtmlValidationIssue {
check: "Verschachtelte interaktive Elemente".to_string(),
count: a.nested_interactive_count,
severity: "high".to_string(),
detail: format!("{} button/a in button/a", a.nested_interactive_count),
});
}
html_issues
}
async fn run_w3c_html_validation(
page: &Page,
_url: &str,
a: &mut PageHealthAnalysis,
) -> Result<()> {
let html = extract_document_html(page).await?;
let issues = validate_html_locally(&html);
a.html_issues.extend(issues);
a.html_validator_status = "executed".to_string();
a.html_validator_detail = Some("HTML5-Validierung lokal via html5ever".to_string());
Ok(())
}
fn validate_html_locally(html: &str) -> Vec<HtmlValidationIssue> {
let dom: RcDom = parse_document(RcDom::default(), Default::default()).one(html);
let errors = dom.errors;
if errors.is_empty() {
return Vec::new();
}
let parts: Vec<String> = errors.iter().take(3).map(|e| e.to_string()).collect();
let detail = if errors.len() <= 3 {
parts.join(" | ")
} else {
format!("{} | +{} weitere", parts.join(" | "), errors.len() - 3)
};
vec![HtmlValidationIssue {
check: "HTML5-Parsing-Fehler".to_string(),
count: errors.len() as u32,
severity: "high".to_string(),
detail,
}]
}
async fn extract_document_html(page: &Page) -> Result<String> {
let js = r#"
(() => {
const d = document.doctype;
const doctype = d
? `<!DOCTYPE ${d.name}${d.publicId ? ` PUBLIC "${d.publicId}"` : ''}${d.systemId ? ` "${d.systemId}"` : ''}>`
: '<!DOCTYPE html>';
return doctype + '\n' + document.documentElement.outerHTML;
})()
"#;
let result = page.evaluate(js).await.map_err(|e| {
AuditError::CdpError(format!("HTML extraction for validator failed: {}", e))
})?;
result
.value()
.and_then(|v| v.as_str())
.map(str::to_string)
.ok_or_else(|| {
AuditError::CdpError("Validator HTML extraction returned no string".to_string())
})
}
async fn run_http_probes(url: &str, a: &mut PageHealthAnalysis) {
let Ok(parsed) = url::Url::parse(url) else {
return;
};
let origin = format!("{}://{}", parsed.scheme(), parsed.host_str().unwrap_or(""));
let probe_url = format!("{}/auditmysite-404-probe-xyz123", origin);
let (soft_404_result, www_result) =
tokio::join!(probe_status(&probe_url), check_www_consolidation(url));
if let Some(status) = soft_404_result {
a.soft_404_status = Some(status);
a.is_soft_404 = status == 200;
debug!("Soft-404 probe: {} → {}", probe_url, status);
}
a.www_consolidation = www_result;
}
async fn probe_status(url: &str) -> Option<u16> {
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(8))
.user_agent("auditmysite-probe/1.0")
.build()
.ok()?;
client
.get(url)
.send()
.await
.ok()
.map(|r| r.status().as_u16())
}
async fn check_www_consolidation(url: &str) -> Option<WwwConsolidation> {
let parsed = url::Url::parse(url).ok()?;
let host = parsed.host_str()?;
if host == "localhost" || host.parse::<std::net::IpAddr>().is_ok() {
return None;
}
let (www_url, non_www_url) = if host.starts_with("www.") {
let non_www_host = host.strip_prefix("www.")?;
let non_www = url.replacen(host, non_www_host, 1);
(url.to_string(), non_www)
} else {
let parts: Vec<&str> = host.split('.').collect();
if parts.len() != 2 {
return None;
}
let www_host = format!("www.{}", host);
let www = url.replacen(host, &www_host, 1);
(www, url.to_string())
};
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(8))
.user_agent("auditmysite-probe/1.0")
.build()
.ok()?;
let (www_resp, non_www_resp) = tokio::join!(
client.head(&www_url).send(),
client.head(&non_www_url).send()
);
let www_status = www_resp.as_ref().ok().map(|r| r.status().as_u16());
let non_www_status = non_www_resp.as_ref().ok().map(|r| r.status().as_u16());
let www_location = www_resp
.ok()
.and_then(|r| r.headers().get("location").cloned())
.and_then(|v| v.to_str().ok().map(String::from))
.unwrap_or_default();
let non_www_location = non_www_resp
.ok()
.and_then(|r| r.headers().get("location").cloned())
.and_then(|v| v.to_str().ok().map(String::from))
.unwrap_or_default();
let www_redirects = matches!(www_status, Some(301) | Some(302) | Some(307) | Some(308))
&& !www_location.contains("www.");
let non_www_redirects = matches!(
non_www_status,
Some(301) | Some(302) | Some(307) | Some(308)
) && non_www_location.contains("www.");
let is_consolidated = www_redirects || non_www_redirects;
let canonical_variant = if www_redirects {
"non-www".to_string()
} else if non_www_redirects {
"www".to_string()
} else if is_consolidated {
"consistent".to_string()
} else {
"inconsistent".to_string()
};
Some(WwwConsolidation {
www_status,
non_www_status,
www_redirects_to_non_www: www_redirects,
non_www_redirects_to_www: non_www_redirects,
canonical_variant,
is_consolidated,
})
}
fn collect_issues(a: &PageHealthAnalysis) -> Vec<PageHealthIssue> {
let mut issues = Vec::new();
if a.is_soft_404 {
issues.push(PageHealthIssue {
issue_type: "soft_404".to_string(),
message: format!(
"Server gibt HTTP {} für nicht-existierende URLs zurück (Soft 404)",
a.soft_404_status.unwrap_or(200)
),
severity: "high".to_string(),
});
}
if a.has_meta_refresh {
let delay = a
.meta_refresh_content
.as_deref()
.and_then(|c| c.split(';').next())
.and_then(|s| s.trim().parse::<u32>().ok())
.unwrap_or(0);
issues.push(PageHealthIssue {
issue_type: "meta_refresh".to_string(),
message: if delay == 0 {
"Sofort-Weiterleitung via meta-refresh (SEO-schädlich, nutze 301-Redirect)"
.to_string()
} else {
format!("meta-refresh mit {}s Verzögerung gefunden", delay)
},
severity: if delay == 0 { "high" } else { "medium" }.to_string(),
});
}
if a.frame_count > 0 {
issues.push(PageHealthIssue {
issue_type: "frames".to_string(),
message: format!("{} veraltete <frame>-Elemente gefunden", a.frame_count),
severity: "high".to_string(),
});
}
if a.url_is_too_long {
issues.push(PageHealthIssue {
issue_type: "url_too_long".to_string(),
message: format!(
"URL mit {} Zeichen überschreitet Empfehlung (>115)",
a.url_length
),
severity: "low".to_string(),
});
}
if a.url_is_too_deep {
issues.push(PageHealthIssue {
issue_type: "url_too_deep".to_string(),
message: format!(
"URL-Pfadtiefe {} überschreitet Empfehlung (>5 Ebenen)",
a.url_path_depth
),
severity: "low".to_string(),
});
}
if a.url_has_query_params {
issues.push(PageHealthIssue {
issue_type: "dynamic_url".to_string(),
message: "URL enthält Query-Parameter (dynamische URL)".to_string(),
severity: "low".to_string(),
});
}
if a.own_redirect_detected {
issues.push(PageHealthIssue {
issue_type: "redirect".to_string(),
message: format!(
"Seite leitet weiter zu: {}",
a.own_final_url.as_deref().unwrap_or("(unbekannt)")
),
severity: "medium".to_string(),
});
}
if let Some(ref www) = a.www_consolidation {
if !www.is_consolidated {
issues.push(PageHealthIssue {
issue_type: "www_not_consolidated".to_string(),
message: "www und non-www Version sind nicht konsolidiert (kein 301-Redirect)"
.to_string(),
severity: "medium".to_string(),
});
}
}
issues
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_analyze_url_basic() {
let mut a = PageHealthAnalysis::default();
analyze_url("https://example.com/foo/bar?q=1", &mut a);
assert_eq!(a.url_length, 31);
assert!(a.url_has_query_params);
assert!(a.url_is_dynamic);
assert_eq!(a.url_path_depth, 2);
assert!(!a.url_is_too_long);
assert!(!a.url_is_too_deep);
}
#[test]
fn test_analyze_url_long() {
let long_url = format!("https://example.com/{}", "a".repeat(100));
let mut a = PageHealthAnalysis::default();
analyze_url(&long_url, &mut a);
assert!(a.url_is_too_long);
}
#[test]
fn test_analyze_url_deep() {
let mut a = PageHealthAnalysis::default();
analyze_url("https://example.com/a/b/c/d/e/f", &mut a);
assert_eq!(a.url_path_depth, 6);
assert!(a.url_is_too_deep);
}
#[test]
fn test_collect_issues_soft_404() {
let a = PageHealthAnalysis {
is_soft_404: true,
soft_404_status: Some(200),
..Default::default()
};
let issues = collect_issues(&a);
assert!(issues.iter().any(|i| i.issue_type == "soft_404"));
}
#[test]
fn test_collect_issues_meta_refresh() {
let a = PageHealthAnalysis {
has_meta_refresh: true,
meta_refresh_content: Some("0; url=https://example.com".to_string()),
..Default::default()
};
let issues = collect_issues(&a);
assert!(issues.iter().any(|i| i.issue_type == "meta_refresh"));
assert_eq!(
issues
.iter()
.find(|i| i.issue_type == "meta_refresh")
.map(|i| i.severity.as_str()),
Some("high")
);
}
#[test]
fn test_build_html_issues_for_classic_validation_findings() {
let analysis = PageHealthAnalysis {
duplicate_id_count: 2,
images_without_alt: 3,
tables_without_headers: 1,
empty_headings: 2,
nested_interactive_count: 1,
..Default::default()
};
let parsed = json!({
"duplicateIdSamples": ["hero", "cta-button"]
});
let issues = build_html_issues(&analysis, &parsed);
assert!(issues.iter().any(|i| {
i.check == "Doppelte IDs"
&& i.count == 2
&& i.detail.contains("hero")
&& i.detail.contains("cta-button")
}));
assert!(issues
.iter()
.any(|i| i.check == "Bilder ohne alt-Attribut" && i.count == 3));
assert!(issues
.iter()
.any(|i| i.check == "Tabellen ohne Kopfzeile" && i.count == 1));
assert!(issues
.iter()
.any(|i| i.check == "Leere Überschriften" && i.count == 2));
assert!(issues
.iter()
.any(|i| i.check == "Verschachtelte interaktive Elemente" && i.count == 1));
}
}