#[derive(Debug, Clone)]
pub struct LinkCheckResult {
pub url: String,
pub status_code: Option<u16>,
pub is_broken: bool,
pub error_type: Option<LinkCheckErrorType>,
pub error_message: Option<String>,
pub response_time_ms: u64,
}
#[cfg(feature = "linkcheck")]
#[derive(Debug, Clone)]
pub enum LinkCheckErrorType {
HttpError(u16),
NetworkError,
Timeout,
InvalidUrl,
TlsError,
ConnectionError,
}
#[cfg(feature = "linkcheck")]
use crate::models::models::LinkInfo;
use crate::models::models::{AnalyzeError, Result};
#[cfg(feature = "linkcheck")]
use reqwest::Client;
#[cfg(feature = "linkcheck")]
use std::time::Duration;
#[cfg(feature = "linkcheck")]
use tokio::time::timeout;
#[cfg(feature = "linkcheck")]
#[derive(Debug, Clone)]
pub struct LinkCheckConfig {
pub max_links: usize,
pub timeout_seconds: u64,
pub max_concurrent: usize,
pub user_agent: String,
pub follow_redirects: bool,
}
#[cfg(feature = "linkcheck")]
impl Default for LinkCheckConfig {
fn default() -> Self {
Self {
max_links: 30,
timeout_seconds: 10,
max_concurrent: 5,
user_agent: "WebpageQualityAnalyzer/0.1.0".to_owned(),
follow_redirects: true,
}
}
}
#[cfg(feature = "linkcheck")]
#[derive(Debug)]
pub struct LinkChecker {
client: Client,
config: LinkCheckConfig,
}
#[cfg(feature = "linkcheck")]
impl LinkChecker {
pub fn new() -> Result<Self> {
let config = LinkCheckConfig::default();
Self::with_config(config)
}
pub fn with_config(config: LinkCheckConfig) -> Result<Self> {
let client = Client::builder()
.user_agent(&config.user_agent)
.redirect(if config.follow_redirects {
reqwest::redirect::Policy::limited(3)
} else {
reqwest::redirect::Policy::none()
})
.timeout(Duration::from_secs(config.timeout_seconds))
.build()
.map_err(|e| AnalyzeError::LinkCheckError(e))?;
Ok(Self { client, config })
}
pub async fn check_link(&self, url: &str) -> LinkCheckResult {
let start_time = std::time::Instant::now();
let result = timeout(
Duration::from_secs(self.config.timeout_seconds),
self.client.head(url).send(),
)
.await;
let response_time_ms = start_time.elapsed().as_millis() as u64;
match result {
Ok(Ok(response)) => {
let status_code = response.status().as_u16();
let is_broken =
!response.status().is_success() && !response.status().is_redirection();
LinkCheckResult {
url: url.to_owned(),
status_code: Some(status_code),
is_broken,
error_type: if is_broken {
Some(LinkCheckErrorType::HttpError(status_code))
} else {
None
},
error_message: if is_broken {
Some(format!("HTTP {}", status_code))
} else {
None
},
response_time_ms,
}
}
Ok(Err(e)) => {
let (error_type, error_message) = categorize_reqwest_error(&e);
LinkCheckResult {
url: url.to_owned(),
status_code: None,
is_broken: true,
error_type: Some(error_type),
error_message: Some(error_message),
response_time_ms,
}
}
Err(_) => LinkCheckResult {
url: url.to_owned(),
status_code: None,
is_broken: true,
error_type: Some(LinkCheckErrorType::Timeout),
error_message: Some("Request timeout".to_owned()),
response_time_ms,
},
}
}
pub async fn check_links(&self, links: &[LinkInfo]) -> Result<Vec<LinkCheckResult>> {
use futures::stream::{self, StreamExt};
let external_links: Vec<_> = links
.iter()
.filter(|link| {
!link.is_internal
&& !link.raw_href.starts_with('#')
&& !link.raw_href.starts_with("mailto:")
})
.take(self.config.max_links)
.collect();
if external_links.is_empty() {
return Ok(Vec::new());
}
let results = stream::iter(external_links)
.map(|link| async move { self.check_link(&link.resolved_href).await })
.buffer_unordered(self.config.max_concurrent)
.collect::<Vec<_>>()
.await;
Ok(results)
}
pub fn calculate_broken_rate(&self, results: &[LinkCheckResult]) -> f32 {
if results.is_empty() {
return 0.0;
}
let broken_count = results.iter().filter(|r| r.is_broken).count();
broken_count as f32 / results.len() as f32
}
pub async fn check_and_rate(&self, links: &[LinkInfo]) -> Result<f32> {
let results = self.check_links(links).await?;
Ok(self.calculate_broken_rate(&results))
}
}
#[cfg(feature = "linkcheck")]
pub fn create_link_checker() -> Result<LinkChecker> {
LinkChecker::new()
}
#[cfg(feature = "linkcheck")]
pub fn create_link_checker_with_config(config: LinkCheckConfig) -> Result<LinkChecker> {
LinkChecker::with_config(config)
}
#[cfg(not(feature = "linkcheck"))]
pub struct LinkChecker;
#[cfg(not(feature = "linkcheck"))]
impl LinkChecker {
pub fn new() -> Result<Self> {
Err(AnalyzeError::InternalError(
"Link checking feature not enabled".to_owned(),
))
}
}
#[cfg(feature = "linkcheck")]
fn categorize_reqwest_error(error: &reqwest::Error) -> (LinkCheckErrorType, String) {
if error.is_timeout() {
(LinkCheckErrorType::Timeout, "Request timeout".to_owned())
} else if error.is_connect() {
(
LinkCheckErrorType::ConnectionError,
format!("Connection failed: {}", error),
)
} else if error.is_request() {
if error.to_string().contains("invalid URL") {
(
LinkCheckErrorType::InvalidUrl,
format!("Invalid URL: {}", error),
)
} else {
(
LinkCheckErrorType::NetworkError,
format!("Request error: {}", error),
)
}
} else {
(
LinkCheckErrorType::NetworkError,
format!("Network error: {}", error),
)
}
}