use crate::models::models::{AnalyzeError, Result};
#[cfg(feature = "async")]
use once_cell::sync::Lazy;
#[cfg(feature = "async")]
use reqwest::Client;
#[cfg(feature = "async")]
use std::collections::HashMap;
#[cfg(feature = "async")]
use std::sync::{Arc, Mutex};
#[cfg(feature = "async")]
use std::time::Duration;
#[cfg(feature = "async")]
use tokio::time::sleep;
#[cfg(feature = "async")]
static HTTP_CLIENT: Lazy<Client> = Lazy::new(|| {
Client::builder()
.timeout(Duration::from_secs(30)) .pool_max_idle_per_host(10) .pool_idle_timeout(Duration::from_secs(90))
.build()
.unwrap_or_else(|_| {
Client::new()
})
});
#[cfg(feature = "async")]
#[derive(Debug, Clone)]
struct CacheEntry {
content: String,
headers: reqwest::header::HeaderMap,
size: usize,
timestamp: std::time::Instant,
}
#[cfg(feature = "async")]
static RESPONSE_CACHE: Lazy<Arc<Mutex<HashMap<String, CacheEntry>>>> =
Lazy::new(|| Arc::new(Mutex::new(HashMap::new())));
#[derive(Debug, Clone, Copy)]
pub struct RetryConfig {
pub max_retries: u32,
pub initial_delay_ms: u64,
pub max_delay_ms: u64,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 3,
initial_delay_ms: 100,
max_delay_ms: 5000,
}
}
}
#[derive(Debug, Clone)]
pub struct FetchOptions {
#[cfg(feature = "async")]
pub timeout_seconds: u64,
#[cfg(feature = "async")]
pub user_agent: String,
#[cfg(feature = "async")]
pub follow_redirects: bool,
#[cfg(feature = "async")]
pub max_redirects: usize,
#[cfg(feature = "async")]
pub javascript_rendering: bool,
#[cfg(feature = "async")]
pub cache_ttl_seconds: u64,
#[cfg(feature = "async")]
pub retry_config: RetryConfig,
#[cfg(not(feature = "async"))]
_phantom: (),
}
impl Default for FetchOptions {
fn default() -> Self {
Self {
#[cfg(feature = "async")]
timeout_seconds: 10,
#[cfg(feature = "async")]
user_agent: "Mozilla/5.0 (compatible; PageQualityAnalyzer/1.0; +https://github.com/NotGyashu/webpage-quality-analyser)".to_string(),
#[cfg(feature = "async")]
follow_redirects: true,
#[cfg(feature = "async")]
max_redirects: 5,
#[cfg(feature = "async")]
javascript_rendering: false,
#[cfg(feature = "async")]
cache_ttl_seconds: 300, #[cfg(feature = "async")]
retry_config: RetryConfig::default(),
#[cfg(not(feature = "async"))]
_phantom: (),
}
}
}
#[derive(Debug)]
pub struct WebFetcher {
#[cfg(feature = "async")]
options: FetchOptions,
}
impl WebFetcher {
pub fn new(options: FetchOptions) -> Result<Self> {
#[cfg(feature = "async")]
{
Ok(Self { options })
}
#[cfg(not(feature = "async"))]
{
Err(crate::models::models::AnalyzeError::InternalError(
"Async functionality not available. Enable the 'async' feature to use URL fetching.".to_string()
))
}
}
#[cfg(feature = "async")]
fn request_builder(&self, url: &str) -> reqwest::RequestBuilder {
HTTP_CLIENT
.get(url)
.timeout(Duration::from_secs(self.options.timeout_seconds))
.header("User-Agent", &self.options.user_agent)
}
#[cfg(feature = "async")]
pub async fn fetch_html(&self, url: &str) -> Result<String> {
let response = self
.request_builder(url)
.send()
.await
.map_err(|e| AnalyzeError::InternalError(e.to_string()))?;
if !response.status().is_success() {
return Err(AnalyzeError::InternalError(format!(
"HTTP {}: Failed to fetch {}",
response.status(),
url
)));
}
let html = response
.text()
.await
.map_err(|e| AnalyzeError::InternalError(e.to_string()))?;
Ok(html)
}
#[cfg(feature = "async")]
pub async fn fetch_with_headers(
&self,
url: &str,
) -> Result<(String, reqwest::header::HeaderMap)> {
let response = self
.request_builder(url)
.send()
.await
.map_err(|e| AnalyzeError::InternalError(e.to_string()))?;
if !response.status().is_success() {
return Err(AnalyzeError::InternalError(format!(
"HTTP {}: Failed to fetch {}",
response.status(),
url
)));
}
let headers = response.headers().clone();
let html = response
.text()
.await
.map_err(|e| AnalyzeError::InternalError(e.to_string()))?;
Ok((html, headers))
}
#[cfg(feature = "async")]
pub async fn fetch_with_metrics(
&self,
url: &str,
) -> Result<(String, reqwest::header::HeaderMap, usize)> {
let response = self
.request_builder(url)
.send()
.await
.map_err(|e| AnalyzeError::InternalError(e.to_string()))?;
if !response.status().is_success() {
return Err(AnalyzeError::InternalError(format!(
"HTTP {}: Failed to fetch {}",
response.status(),
url
)));
}
let headers = response.headers().clone();
let bytes = response
.bytes()
.await
.map_err(|e| AnalyzeError::InternalError(e.to_string()))?;
let response_size = bytes.len();
let html = String::from_utf8(bytes.to_vec())
.map_err(|e| AnalyzeError::InternalError(format!("Invalid UTF-8: {}", e)))?;
Ok((html, headers, response_size))
}
#[cfg(feature = "async")]
pub fn options(&self) -> &FetchOptions {
&self.options
}
#[cfg(feature = "async")]
fn get_cached(&self, url: &str) -> Option<CacheEntry> {
if let Ok(cache) = RESPONSE_CACHE.lock() {
if let Some(entry) = cache.get(url) {
let age = entry.timestamp.elapsed().as_secs();
if age < self.options.cache_ttl_seconds {
return Some(entry.clone());
}
}
}
None
}
#[cfg(feature = "async")]
fn cache_response(
&self,
url: &str,
content: String,
headers: reqwest::header::HeaderMap,
size: usize,
) {
if self.options.cache_ttl_seconds == 0 {
return;
}
if let Ok(mut cache) = RESPONSE_CACHE.lock() {
cache.insert(
url.to_string(),
CacheEntry {
content,
headers,
size,
timestamp: std::time::Instant::now(),
},
);
let ttl = Duration::from_secs(self.options.cache_ttl_seconds);
cache.retain(|_, entry| entry.timestamp.elapsed() < ttl);
}
}
#[cfg(feature = "async")]
async fn fetch_with_retry(&self, url: &str) -> Result<reqwest::Response> {
let mut last_error: Option<AnalyzeError> = None;
let mut delay = self.options.retry_config.initial_delay_ms;
for attempt in 0..=self.options.retry_config.max_retries {
match self.request_builder(url).send().await {
Ok(response) => {
if response.status().is_success() {
return Ok(response);
} else if response.status().is_server_error()
&& attempt < self.options.retry_config.max_retries
{
last_error = Some(AnalyzeError::InternalError(format!(
"HTTP {}: Server error (attempt {})",
response.status(),
attempt + 1
)));
} else {
return Err(AnalyzeError::InternalError(format!(
"HTTP {}: Failed to fetch {}",
response.status(),
url
)));
}
}
Err(e) => {
last_error = Some(AnalyzeError::InternalError(e.to_string()));
if attempt < self.options.retry_config.max_retries {
sleep(Duration::from_millis(delay)).await;
delay = (delay * 2).min(self.options.retry_config.max_delay_ms);
}
}
}
}
Err(last_error.unwrap_or_else(|| AnalyzeError::InternalError("Unknown error".to_string())))
}
#[cfg(feature = "async")]
pub async fn fetch_html_cached(&self, url: &str) -> Result<String> {
if let Some(cached) = self.get_cached(url) {
return Ok(cached.content);
}
let response = self.fetch_with_retry(url).await?;
let headers = response.headers().clone();
let html = response
.text()
.await
.map_err(|e| AnalyzeError::InternalError(e.to_string()))?;
let size = html.len();
self.cache_response(url, html.clone(), headers, size);
Ok(html)
}
#[cfg(feature = "async")]
pub async fn fetch_with_metrics_cached(
&self,
url: &str,
) -> Result<(String, reqwest::header::HeaderMap, usize)> {
if let Some(cached) = self.get_cached(url) {
return Ok((cached.content, cached.headers, cached.size));
}
let response = self.fetch_with_retry(url).await?;
let headers = response.headers().clone();
let bytes = response
.bytes()
.await
.map_err(|e| AnalyzeError::InternalError(e.to_string()))?;
let response_size = bytes.len();
let html = String::from_utf8(bytes.to_vec())
.map_err(|e| AnalyzeError::InternalError(format!("Invalid UTF-8: {}", e)))?;
self.cache_response(url, html.clone(), headers.clone(), response_size);
Ok((html, headers, response_size))
}
#[cfg(feature = "async")]
pub fn clear_cache() {
if let Ok(mut cache) = RESPONSE_CACHE.lock() {
cache.clear();
}
}
#[cfg(feature = "async")]
pub fn cache_stats() -> (usize, usize) {
if let Ok(cache) = RESPONSE_CACHE.lock() {
let total_entries = cache.len();
let total_size: usize = cache.values().map(|entry| entry.size).sum();
(total_entries, total_size)
} else {
(0, 0)
}
}
}