use chromiumoxide::Page;
use serde::{Deserialize, Serialize};
use tracing::info;
use url::Url;
use crate::error::{AuditError, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlockingResource {
pub url: String,
pub transfer_bytes: u64,
pub kind: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenderBlockingAnalysis {
pub blocking_scripts: Vec<BlockingResource>,
pub blocking_css: Vec<BlockingResource>,
pub blocking_transfer_bytes: u64,
pub first_party_bytes: u64,
pub third_party_bytes: u64,
pub third_party_origin_count: u32,
pub suggestions: Vec<String>,
}
impl RenderBlockingAnalysis {
pub fn blocking_count(&self) -> usize {
self.blocking_scripts.len() + self.blocking_css.len()
}
pub fn has_blocking(&self) -> bool {
self.blocking_count() > 0
}
}
pub async fn analyze_render_blocking(
page: &Page,
page_url: &str,
) -> Result<RenderBlockingAnalysis> {
info!("Analyzing render-blocking resources...");
let js = r#"
(() => {
const result = {};
// Scripts in <head> without defer or async (render-blocking)
result.blockingScriptUrls = Array.from(
document.head ? document.head.querySelectorAll('script[src]') : []
)
.filter(s => !s.defer && !s.async && !(s.type || '').includes('module'))
.map(s => s.src)
.filter(Boolean);
// CSS <link rel=stylesheet> in <head> for screen/all media (render-blocking)
result.blockingCssUrls = Array.from(
document.head ? document.head.querySelectorAll('link[rel="stylesheet"]') : []
)
.filter(l => {
const m = (l.media || 'all').trim().toLowerCase();
return m === '' || m === 'all' || m === 'screen';
})
.map(l => l.href)
.filter(Boolean);
// Resource timing: build url → transferSize map
const timing = {};
for (const r of performance.getEntriesByType('resource')) {
timing[r.name] = r.transferSize || 0;
}
result.resourceTiming = timing;
// All resource URLs and sizes for first/third party split
result.allResources = Array.from(performance.getEntriesByType('resource')).map(r => ({
url: r.name,
transferSize: r.transferSize || 0,
}));
return JSON.stringify(result);
})()
"#;
let js_result = page
.evaluate(js)
.await
.map_err(|e| AuditError::CdpError(format!("Render-blocking analysis 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 timing = parsed["resourceTiming"]
.as_object()
.cloned()
.unwrap_or_default();
let lookup_size = |url: &str| -> u64 { timing.get(url).and_then(|v| v.as_u64()).unwrap_or(0) };
let blocking_scripts: Vec<BlockingResource> = parsed["blockingScriptUrls"]
.as_array()
.unwrap_or(&vec![])
.iter()
.filter_map(|v| v.as_str())
.map(|url| BlockingResource {
url: truncate(url, 120),
transfer_bytes: lookup_size(url),
kind: "script".to_string(),
})
.collect();
let blocking_css: Vec<BlockingResource> = parsed["blockingCssUrls"]
.as_array()
.unwrap_or(&vec![])
.iter()
.filter_map(|v| v.as_str())
.map(|url| BlockingResource {
url: truncate(url, 120),
transfer_bytes: lookup_size(url),
kind: "css".to_string(),
})
.collect();
let blocking_transfer_bytes: u64 = blocking_scripts
.iter()
.chain(blocking_css.iter())
.map(|r| r.transfer_bytes)
.sum();
let page_host = Url::parse(page_url)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_string()))
.unwrap_or_default();
let mut first_party_bytes = 0u64;
let mut third_party_bytes = 0u64;
let mut third_party_origins = std::collections::HashSet::new();
if let Some(resources) = parsed["allResources"].as_array() {
for r in resources {
let url_str = r["url"].as_str().unwrap_or("");
let size = r["transferSize"].as_u64().unwrap_or(0);
let is_first_party = Url::parse(url_str)
.ok()
.and_then(|u| u.host_str().map(|h| h == page_host))
.unwrap_or(true);
if is_first_party {
first_party_bytes += size;
} else {
third_party_bytes += size;
if let Ok(u) = Url::parse(url_str) {
if let Some(h) = u.host_str() {
third_party_origins.insert(h.to_string());
}
}
}
}
}
let suggestions = render_blocking_suggestions(
blocking_scripts.len(),
blocking_css.len(),
third_party_bytes,
third_party_origins.len() as u32,
true,
);
info!(
"Render-blocking: {} scripts, {} CSS, {} KB blocking",
blocking_scripts.len(),
blocking_css.len(),
blocking_transfer_bytes / 1024,
);
Ok(RenderBlockingAnalysis {
blocking_scripts,
blocking_css,
blocking_transfer_bytes,
first_party_bytes,
third_party_bytes,
third_party_origin_count: third_party_origins.len() as u32,
suggestions,
})
}
pub fn render_blocking_suggestions(
script_count: usize,
css_count: usize,
third_party_bytes: u64,
third_party_origins: u32,
en: bool,
) -> Vec<String> {
let mut s = Vec::new();
if script_count > 0 {
s.push(if en {
format!(
"{} script{} in <head> without defer/async block rendering. \
Add defer or async.",
script_count,
if script_count == 1 { "" } else { "s" },
)
} else {
format!(
"{} Script{} im <head> ohne defer/async blockier{} das Rendering. \
Füge defer oder async hinzu.",
script_count,
if script_count == 1 { "" } else { "s" },
if script_count == 1 { "t" } else { "en" },
)
});
}
if css_count > 0 {
s.push(if en {
format!(
"{} CSS file{} block rendering. \
Inline critical CSS or use <link rel=\"preload\">.",
css_count,
if css_count == 1 { "" } else { "s" },
)
} else {
format!(
"{} CSS-Datei{} blockier{} das Rendering. \
Inline kritisches CSS oder nutze <link rel=\"preload\">.",
css_count,
if css_count == 1 { "" } else { "en" },
if css_count == 1 { "t" } else { "en" },
)
});
}
if third_party_bytes > 100_000 {
let kb = third_party_bytes as f64 / 1024.0;
s.push(if en {
format!(
"Third-party resources: {:.0} KB from {} domain{}. \
Check whether all scripts are really necessary.",
kb,
third_party_origins,
if third_party_origins == 1 { "" } else { "s" },
)
} else {
format!(
"Third-Party-Ressourcen: {:.0} KB von {} Domain{}. \
Prüfe, ob alle Skripte wirklich notwendig sind.",
kb,
third_party_origins,
if third_party_origins == 1 { "" } else { "s" },
)
});
}
s
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let target = max.saturating_sub(3);
let boundary = s
.char_indices()
.take_while(|(i, _)| *i <= target)
.last()
.map(|(i, _)| i)
.unwrap_or(0);
format!("{}…", &s[..boundary])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_suggestions_single_script() {
let s = render_blocking_suggestions(1, 0, 0, 0, true);
assert_eq!(s.len(), 1);
assert!(s[0].contains("defer"));
}
#[test]
fn test_build_suggestions_third_party() {
let s = render_blocking_suggestions(0, 0, 200_000, 3, true);
assert_eq!(s.len(), 1);
assert!(s[0].contains("Third-party"));
}
#[test]
fn test_build_suggestions_empty() {
let s = render_blocking_suggestions(0, 0, 0, 0, true);
assert!(s.is_empty());
}
#[test]
fn test_build_suggestions_canonical_english_no_umlauts() {
let s = render_blocking_suggestions(2, 3, 200_000, 2, true);
assert_eq!(s.len(), 3);
assert!(!s.iter().any(|x| x.chars().any(|c| "äöüßÄÖÜ".contains(c))));
}
#[test]
fn test_blocking_count() {
let analysis = RenderBlockingAnalysis {
blocking_scripts: vec![BlockingResource {
url: "a.js".into(),
transfer_bytes: 1000,
kind: "script".into(),
}],
blocking_css: vec![
BlockingResource {
url: "b.css".into(),
transfer_bytes: 500,
kind: "css".into(),
},
BlockingResource {
url: "c.css".into(),
transfer_bytes: 300,
kind: "css".into(),
},
],
blocking_transfer_bytes: 1800,
first_party_bytes: 10000,
third_party_bytes: 5000,
third_party_origin_count: 2,
suggestions: vec![],
};
assert_eq!(analysis.blocking_count(), 3);
assert!(analysis.has_blocking());
}
}