use chromiumoxide::Page;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::error::{AuditError, Result};
use crate::taxonomy::Severity;
use crate::util::truncate_url;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HeadingStructure {
pub h1_count: usize,
pub h1_text: Option<String>,
pub headings: Vec<HeadingInfo>,
pub issues: Vec<HeadingIssue>,
pub total_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeadingInfo {
pub level: u8,
pub text: String,
pub length: usize,
#[serde(default)]
pub is_question: bool,
#[serde(default)]
pub in_faq_context: bool,
#[serde(default)]
pub word_count_after: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeadingIssue {
pub issue_type: String,
pub message: String,
pub severity: Severity,
}
pub async fn analyze_heading_structure(page: &Page) -> Result<HeadingStructure> {
info!("Analyzing heading structure...");
let js_code = r#"
(() => {
const headingEls = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
const countWords = (s) => {
const t = s.trim();
return t.length ? t.split(/\s+/).length : 0;
};
const headings = headingEls.map((h, i) => {
const level = parseInt(h.tagName.charAt(1));
const text = h.textContent.trim();
const is_question = text.endsWith('?');
const in_faq_context = !!(
h.closest('[itemtype*="Question"]') ||
h.closest('[itemtype*="FAQPage"]') ||
h.closest('details') ||
h.closest('.faq') ||
h.closest('[class*="faq"]') ||
h.closest('[id*="faq"]')
);
// Word count of the visible text between this heading and the next
// heading (or end of the content area for the last heading).
let word_count_after = 0;
try {
const range = document.createRange();
range.setStartAfter(h);
const next = headingEls[i + 1];
if (next) {
range.setEndBefore(next);
} else if (document.body.lastChild) {
range.setEndAfter(document.body.lastChild);
} else {
range.setEnd(document.body, 0);
}
const frag = range.cloneContents();
const container = document.createElement('div');
container.appendChild(frag);
container.querySelectorAll('script, style, noscript').forEach(e => e.remove());
word_count_after = countWords(container.textContent || '');
} catch (e) {
word_count_after = 0;
}
return { level, text, length: text.length, is_question, in_faq_context, word_count_after };
});
return JSON.stringify(headings);
})()
"#;
let js_result = page
.evaluate(js_code)
.await
.map_err(|e| AuditError::CdpError(format!("Heading analysis failed: {}", e)))?;
let json_str = js_result.value().and_then(|v| v.as_str()).unwrap_or("[]");
let headings: Vec<HeadingInfo> = serde_json::from_str(json_str).unwrap_or_default();
Ok(check_headings_structure(headings))
}
fn check_headings_structure(headings: Vec<HeadingInfo>) -> HeadingStructure {
let h1_headings: Vec<_> = headings.iter().filter(|h| h.level == 1).collect();
let h1_count = h1_headings.len();
let h1_text = h1_headings.first().map(|h| h.text.clone());
let mut issues = Vec::new();
if h1_count == 0 {
issues.push(HeadingIssue {
issue_type: "missing_h1".to_string(),
message: "Page is missing an H1 heading".to_string(),
severity: Severity::High,
});
}
if h1_count > 1 {
issues.push(HeadingIssue {
issue_type: "multiple_h1".to_string(),
message: format!("Page has {} H1 headings (should have exactly 1)", h1_count),
severity: Severity::Medium,
});
}
let mut prev_level = 0u8;
for heading in &headings {
if prev_level > 0 && heading.level > prev_level + 1 {
issues.push(HeadingIssue {
issue_type: "skipped_level".to_string(),
message: format!(
"Heading level skipped: H{} to H{} (\"{}\")",
prev_level,
heading.level,
truncate_url(&heading.text, 40)
),
severity: Severity::Medium,
});
}
prev_level = heading.level;
}
for heading in &headings {
if heading.text.is_empty() {
issues.push(HeadingIssue {
issue_type: "empty_heading".to_string(),
message: format!("Empty H{} heading found", heading.level),
severity: Severity::High,
});
}
}
for heading in &headings {
let max_len = if heading.is_question || heading.in_faq_context {
100
} else if heading.level <= 2 {
70
} else {
90
};
if heading.length > max_len {
issues.push(HeadingIssue {
issue_type: "long_heading".to_string(),
message: format!(
"H{} is too long ({} chars): \"{}...\"",
heading.level,
heading.length,
truncate_url(&heading.text, 30)
),
severity: Severity::Medium,
});
}
}
info!(
"Heading structure: {} total, {} H1s, {} issues",
headings.len(),
h1_count,
issues.len()
);
HeadingStructure {
h1_count,
h1_text,
total_count: headings.len(),
headings,
issues,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_heading_info() {
let heading = HeadingInfo {
level: 1,
text: "Test Heading".to_string(),
length: 12,
is_question: false,
in_faq_context: false,
word_count_after: 0,
};
assert_eq!(heading.level, 1);
assert_eq!(heading.length, 12);
}
#[test]
fn test_faq_question_heading_length() {
let headings = vec![
HeadingInfo {
level: 2,
text: "Wie läuft eine hyperbare Sauerstofftherapie in unserer Praxis in München ab?".to_string(),
length: 76,
is_question: true,
in_faq_context: false,
word_count_after: 0,
},
HeadingInfo {
level: 2,
text: "Normaler Heading mit mehr als 70 Zeichen der eigentlich einen Fehler werfen sollte".to_string(),
length: 82,
is_question: false,
in_faq_context: false,
word_count_after: 0,
}
];
let res = check_headings_structure(headings);
let long_heading_issues: Vec<_> = res
.issues
.iter()
.filter(|i| i.issue_type == "long_heading")
.collect();
assert_eq!(long_heading_issues.len(), 1);
assert!(long_heading_issues[0].message.contains("H2 is too long"));
}
}