use std::collections::{HashMap, HashSet, VecDeque};
use html5ever::{parse_document, tendril::TendrilSink};
use markup5ever_rcdom::{Handle, NodeData, RcDom};
use reqwest::{redirect::Policy, Client};
use tracing::{debug, info, warn};
use url::Url;
use crate::audit::report::{BrokenLink, BrokenLinkSeverity, CrawlDiagnostics, RedirectChain};
use crate::error::{AuditError, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CrawlNode {
pub url: String,
pub depth: usize,
pub links_out: Vec<String>,
pub external_links_out: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CrawlResult {
pub seed_url: String,
pub pages: Vec<CrawlNode>,
}
impl CrawlResult {
pub fn urls(&self) -> Vec<String> {
self.pages.iter().map(|p| p.url.clone()).collect()
}
}
struct LinkCheckResult {
final_status: Option<u16>,
hops: u8,
final_url: String,
error: Option<String>,
}
async fn check_link(client: &Client, url: &str) -> LinkCheckResult {
let mut current = url.to_string();
let mut hops = 0u8;
const MAX_HOPS: u8 = 6;
loop {
let result = client
.head(¤t)
.header("User-Agent", "auditmysite-link-checker/1.0")
.send()
.await;
match result {
Ok(resp) => {
let status = resp.status();
if status.is_redirection() && hops < MAX_HOPS {
if let Some(loc) = resp.headers().get("location").and_then(|v| v.to_str().ok())
{
let next = if let Ok(base) = Url::parse(¤t) {
base.join(loc)
.map(|u| u.to_string())
.unwrap_or_else(|_| loc.to_string())
} else {
loc.to_string()
};
let next = Url::parse(&next)
.ok()
.map(|mut u| {
u.set_fragment(None);
u.to_string()
})
.unwrap_or(next);
current = next;
hops += 1;
continue;
}
}
return LinkCheckResult {
final_status: Some(status.as_u16()),
hops,
final_url: current,
error: None,
};
}
Err(e) => {
if hops == 0 {
match client
.get(url)
.header("User-Agent", "auditmysite-link-checker/1.0")
.send()
.await
{
Ok(resp) => {
return LinkCheckResult {
final_status: Some(resp.status().as_u16()),
hops: 0,
final_url: url.to_string(),
error: None,
};
}
Err(e2) => {
return LinkCheckResult {
final_status: None,
hops: 0,
final_url: url.to_string(),
error: Some(e2.to_string()),
};
}
}
}
return LinkCheckResult {
final_status: None,
hops,
final_url: current,
error: Some(e.to_string()),
};
}
}
}
}
fn broken_link_severity(
is_external: bool,
status: Option<u16>,
error: &Option<String>,
) -> BrokenLinkSeverity {
match (is_external, status) {
(false, Some(s)) if (400..500).contains(&s) => BrokenLinkSeverity::High,
(false, Some(s)) if s >= 500 => BrokenLinkSeverity::Medium,
(false, None) if error.is_some() => BrokenLinkSeverity::High,
(true, Some(s)) if (400..500).contains(&s) => BrokenLinkSeverity::Medium,
(true, Some(s)) if s >= 500 => BrokenLinkSeverity::Low,
(true, None) if error.is_some() => BrokenLinkSeverity::Low,
_ => BrokenLinkSeverity::Low,
}
}
fn unique_internal_targets(crawl: &CrawlResult) -> Vec<String> {
let mut unique = HashSet::new();
for page in &crawl.pages {
for target in &page.links_out {
unique.insert(target.clone());
}
}
let mut targets: Vec<String> = unique.into_iter().collect();
targets.sort();
targets
}
fn unique_external_targets(crawl: &CrawlResult) -> Vec<String> {
let mut unique = HashSet::new();
for page in &crawl.pages {
for target in &page.external_links_out {
unique.insert(target.clone());
}
}
let mut targets: Vec<String> = unique.into_iter().collect();
targets.sort();
targets.truncate(100);
targets
}
pub async fn analyze_crawl_links(crawl: &CrawlResult) -> CrawlDiagnostics {
let client = Client::builder()
.redirect(Policy::none())
.timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| Client::new());
let unique_internal = unique_internal_targets(crawl);
let mut internal_results: HashMap<String, LinkCheckResult> = HashMap::new();
for target in &unique_internal {
internal_results.insert(target.clone(), check_link(&client, target).await);
}
let mut broken_internal_links = Vec::new();
let mut redirect_chains: Vec<RedirectChain> = Vec::new();
for page in &crawl.pages {
for target in &page.links_out {
let Some(res) = internal_results.get(target) else {
continue;
};
if res.hops > 1
&& !redirect_chains
.iter()
.any(|r: &RedirectChain| &r.target_url == target)
{
redirect_chains.push(RedirectChain {
source_url: page.url.clone(),
target_url: target.clone(),
final_url: res.final_url.clone(),
hops: res.hops,
is_external: false,
});
}
let is_broken = matches!(res.final_status, Some(s) if s >= 400)
|| (res.final_status.is_none() && res.error.is_some());
if is_broken {
let severity = broken_link_severity(false, res.final_status, &res.error);
broken_internal_links.push(BrokenLink {
source_url: page.url.clone(),
target_url: target.clone(),
status_code: res.final_status,
error: res.error.clone(),
is_external: false,
redirect_hops: res.hops,
severity,
});
}
}
}
let unique_external = unique_external_targets(crawl);
let mut external_results: HashMap<String, LinkCheckResult> = HashMap::new();
for target in &unique_external {
external_results.insert(target.clone(), check_link(&client, target).await);
}
let mut broken_external_links = Vec::new();
for page in &crawl.pages {
for target in &page.external_links_out {
let Some(res) = external_results.get(target) else {
continue;
};
if res.hops > 1
&& !redirect_chains
.iter()
.any(|r: &RedirectChain| &r.target_url == target)
{
redirect_chains.push(RedirectChain {
source_url: page.url.clone(),
target_url: target.clone(),
final_url: res.final_url.clone(),
hops: res.hops,
is_external: true,
});
}
let is_broken = matches!(res.final_status, Some(s) if s >= 400)
|| (res.final_status.is_none() && res.error.is_some());
if is_broken {
let severity = broken_link_severity(true, res.final_status, &res.error);
broken_external_links.push(BrokenLink {
source_url: page.url.clone(),
target_url: target.clone(),
status_code: res.final_status,
error: res.error.clone(),
is_external: true,
redirect_hops: res.hops,
severity,
});
}
}
}
broken_internal_links.sort_by_key(|b| match b.severity {
BrokenLinkSeverity::High => 0,
BrokenLinkSeverity::Medium => 1,
BrokenLinkSeverity::Low => 2,
});
broken_external_links.sort_by_key(|b| match b.severity {
BrokenLinkSeverity::High => 0,
BrokenLinkSeverity::Medium => 1,
BrokenLinkSeverity::Low => 2,
});
redirect_chains.sort_by_key(|b| std::cmp::Reverse(b.hops));
CrawlDiagnostics {
seed_url: crawl.seed_url.clone(),
discovered_urls: crawl.pages.len(),
checked_internal_links: unique_internal.len(),
broken_internal_links,
checked_external_links: unique_external.len(),
broken_external_links,
redirect_chains,
}
}
pub async fn crawl_site(seed_url: &str, max_urls: usize, max_depth: usize) -> Result<CrawlResult> {
let seed = Url::parse(seed_url)?;
let host = seed
.host_str()
.ok_or_else(|| AuditError::InvalidUrl {
url: seed_url.to_string(),
reason: "Missing host".to_string(),
})?
.to_string();
let effective_max_urls = if max_urls == 0 { usize::MAX } else { max_urls };
info!(
"Starting crawl from {} (max_urls={}, max_depth={})",
seed_url, effective_max_urls, max_depth
);
let mut queue = VecDeque::new();
let mut visited = HashSet::new();
let mut pages = Vec::new();
queue.push_back((normalize_discovered_url(seed.clone())?, 0usize));
while let Some((current, depth)) = queue.pop_front() {
if pages.len() >= effective_max_urls {
break;
}
if !visited.insert(current.as_str().to_string()) {
continue;
}
let html = match fetch_html(current.as_str()).await {
Ok(html) => html,
Err(err) => {
warn!("Failed to fetch crawl page {}: {}", current, err);
continue;
}
};
let (same_domain_links, external_links) = extract_links(¤t, &host, &html);
let mut links_out = same_domain_links;
links_out.sort();
links_out.dedup();
let mut external_links_out = external_links;
external_links_out.sort();
external_links_out.dedup();
debug!(
"Crawled {} at depth {} ({} same-domain links, {} external links)",
current,
depth,
links_out.len(),
external_links_out.len()
);
if depth < max_depth {
for link in &links_out {
if !visited.contains(link) {
queue.push_back((link.clone(), depth + 1));
}
}
}
pages.push(CrawlNode {
url: current,
depth,
links_out,
external_links_out,
});
}
Ok(CrawlResult {
seed_url: seed_url.to_string(),
pages,
})
}
async fn fetch_html(url: &str) -> Result<String> {
let response = reqwest::get(url).await?;
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_ascii_lowercase();
if !content_type.is_empty() && !content_type.contains("text/html") {
return Err(AuditError::OutputError {
reason: format!("URL did not return HTML content: {url}"),
});
}
Ok(response.text().await?)
}
fn extract_links(base_url: &str, expected_host: &str, html: &str) -> (Vec<String>, Vec<String>) {
let dom: RcDom = parse_document(RcDom::default(), Default::default()).one(html);
let mut base_href: Option<String> = None;
let mut hrefs: Vec<String> = Vec::new();
collect_links(&dom.document, &mut base_href, &mut hrefs);
let effective_base = base_href
.as_deref()
.and_then(|b| Url::parse(base_url).ok()?.join(b).ok())
.map(|u| u.to_string())
.unwrap_or_else(|| base_url.to_string());
let mut internal_links = Vec::new();
let mut external_links = Vec::new();
for href in hrefs {
if let Some(url) = normalize_link(&effective_base, expected_host, &href) {
internal_links.push(url);
} else if let Some(url) = normalize_external_link(&effective_base, expected_host, &href) {
if external_links.len() < 50 {
external_links.push(url);
}
}
}
(internal_links, external_links)
}
fn collect_links(handle: &Handle, base_href: &mut Option<String>, hrefs: &mut Vec<String>) {
if let NodeData::Element { name, attrs, .. } = &handle.data {
let tag = name.local.as_ref();
let attrs = attrs.borrow();
let href = || {
attrs
.iter()
.find(|a| a.name.local.as_ref().eq_ignore_ascii_case("href"))
.map(|a| a.value.to_string())
};
if tag.eq_ignore_ascii_case("base") {
if base_href.is_none() {
if let Some(h) = href() {
if !h.trim().is_empty() {
*base_href = Some(h);
}
}
}
} else if tag.eq_ignore_ascii_case("a") {
if let Some(h) = href() {
hrefs.push(h);
}
}
}
for child in handle.children.borrow().iter() {
collect_links(child, base_href, hrefs);
}
}
fn normalize_link(base_url: &str, expected_host: &str, href: &str) -> Option<String> {
let href = href.trim();
if href.is_empty()
|| href.starts_with('#')
|| href.starts_with("mailto:")
|| href.starts_with("tel:")
|| href.starts_with("javascript:")
|| href.starts_with("data:")
{
return None;
}
let base = Url::parse(base_url).ok()?;
let url = base.join(href).ok()?;
if url.scheme() != "http" && url.scheme() != "https" {
return None;
}
if url.host_str()? != expected_host {
return None;
}
normalize_discovered_url(url).ok()
}
fn normalize_external_link(base_url: &str, expected_host: &str, href: &str) -> Option<String> {
let href = href.trim();
if href.is_empty()
|| href.starts_with('#')
|| href.starts_with("mailto:")
|| href.starts_with("tel:")
|| href.starts_with("javascript:")
|| href.starts_with("data:")
{
return None;
}
let base = Url::parse(base_url).ok()?;
let url = base.join(href).ok()?;
if url.scheme() != "http" && url.scheme() != "https" {
return None;
}
let host = url.host_str()?;
if host == expected_host {
return None; }
let mut u = url.clone();
u.set_fragment(None);
Some(u.to_string())
}
fn normalize_discovered_url(mut url: Url) -> Result<String> {
url.set_fragment(None);
let path = url.path().to_string();
if path != "/" && path.ends_with('/') {
url.set_path(path.trim_end_matches('/'));
}
Ok(url.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_links_filters_same_domain_and_noise() {
let html = r##"
<a href="/about">About</a>
<a href="https://www.casoon.de/services/">Services</a>
<a href="mailto:hello@example.com">Mail</a>
<a href="#contact">Fragment</a>
<a href="https://external.example.org/">External</a>
"##;
let (internal_links, external_links) =
extract_links("https://www.casoon.de", "www.casoon.de", html);
assert_eq!(
internal_links,
vec![
"https://www.casoon.de/about".to_string(),
"https://www.casoon.de/services".to_string()
]
);
assert_eq!(
external_links,
vec!["https://external.example.org/".to_string()]
);
}
#[test]
fn test_extract_links_handles_quote_case_and_whitespace_variants() {
let html = r##"
<a href='/single'>single</a>
<a HREF="/upper">upper-attr</a>
<a href = "/spaced" >spaced</a>
<a href=/unquoted>unquoted</a>
"##;
let (internal, _) = extract_links("https://www.casoon.de", "www.casoon.de", html);
assert_eq!(
internal,
vec![
"https://www.casoon.de/single".to_string(),
"https://www.casoon.de/upper".to_string(),
"https://www.casoon.de/spaced".to_string(),
"https://www.casoon.de/unquoted".to_string(),
]
);
}
#[test]
fn test_extract_links_resolves_relative_paths() {
let html = r##"<a href="services/web">rel</a><a href="../about">parent</a>"##;
let (internal, _) =
extract_links("https://www.casoon.de/leistungen/", "www.casoon.de", html);
assert_eq!(
internal,
vec![
"https://www.casoon.de/leistungen/services/web".to_string(),
"https://www.casoon.de/about".to_string(),
]
);
}
#[test]
fn test_extract_links_honors_base_href() {
let html = r##"
<head><base href="https://www.casoon.de/de/"></head>
<body><a href="kontakt">Kontakt</a></body>
"##;
let (internal, _) = extract_links("https://www.casoon.de/", "www.casoon.de", html);
assert_eq!(
internal,
vec!["https://www.casoon.de/de/kontakt".to_string()]
);
}
#[test]
fn test_extract_links_ignores_non_anchor_href() {
let html = r##"
<head>
<link rel="stylesheet" href="/styles.css">
<link rel="icon" href="/favicon.ico">
</head>
<body><a href="/real">Real</a></body>
"##;
let (internal, _) = extract_links("https://www.casoon.de", "www.casoon.de", html);
assert_eq!(internal, vec!["https://www.casoon.de/real".to_string()]);
}
#[test]
fn test_normalize_link_keeps_query_and_strips_fragment() {
let url = normalize_link(
"https://www.casoon.de",
"www.casoon.de",
"/seo-marketing/?ref=nav#hero",
)
.unwrap();
assert_eq!(url, "https://www.casoon.de/seo-marketing?ref=nav");
}
#[test]
fn test_normalize_link_rejects_other_hosts() {
let url = normalize_link(
"https://www.casoon.de",
"www.casoon.de",
"https://example.org/path",
);
assert!(url.is_none());
}
#[test]
fn test_normalize_external_link_captures_external() {
let url = normalize_external_link(
"https://www.casoon.de",
"www.casoon.de",
"https://example.org/path#section",
)
.unwrap();
assert_eq!(url, "https://example.org/path");
}
#[test]
fn test_normalize_external_link_rejects_same_domain() {
let url = normalize_external_link(
"https://www.casoon.de",
"www.casoon.de",
"https://www.casoon.de/about",
);
assert!(url.is_none());
}
#[test]
fn test_unique_internal_targets_deduplicates_across_pages() {
let crawl = CrawlResult {
seed_url: "https://www.casoon.de".to_string(),
pages: vec![
CrawlNode {
url: "https://www.casoon.de".to_string(),
depth: 0,
links_out: vec![
"https://www.casoon.de/about".to_string(),
"https://www.casoon.de/contact".to_string(),
],
external_links_out: vec![],
},
CrawlNode {
url: "https://www.casoon.de/about".to_string(),
depth: 1,
links_out: vec!["https://www.casoon.de/contact".to_string()],
external_links_out: vec![],
},
],
};
let targets = unique_internal_targets(&crawl);
assert_eq!(
targets,
vec![
"https://www.casoon.de/about".to_string(),
"https://www.casoon.de/contact".to_string()
]
);
}
}