use thiserror::Error;
use url::Url;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ContentType {
#[default]
Html,
Xml,
Text,
Other,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoveredUrl {
pub url: Url,
pub depth: u8,
pub parent_url: Url,
pub content_type: ContentType,
}
impl DiscoveredUrl {
#[must_use]
pub fn new(url: Url, depth: u8, parent_url: Url, content_type: ContentType) -> Self {
Self {
url,
depth,
parent_url,
content_type,
}
}
#[must_use]
pub fn html(url: Url, depth: u8, parent_url: Url) -> Self {
Self {
url,
depth,
parent_url,
content_type: ContentType::Html,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct CrawlerConfig {
pub seed_url: Url,
pub max_depth: u8,
pub max_pages: usize,
pub include_patterns: Vec<String>,
pub exclude_patterns: Vec<String>,
pub concurrency: usize,
pub delay_ms: u64,
pub user_agent: String,
pub timeout_secs: u64,
pub use_sitemap: bool,
pub sitemap_url: Option<String>,
}
impl CrawlerConfig {
pub fn builder(seed_url: Url) -> CrawlerConfigBuilder {
CrawlerConfigBuilder::new(seed_url)
}
pub fn new(seed_url: Url) -> Self {
Self {
seed_url,
max_depth: 3,
max_pages: 100,
include_patterns: Vec::new(),
exclude_patterns: Vec::new(),
concurrency: 3, delay_ms: 500, user_agent: "rust-scraper/0.3.0 (Web Crawler)".to_string(),
timeout_secs: 30,
use_sitemap: false,
sitemap_url: None,
}
}
#[inline]
#[must_use]
pub fn matches_include(&self, url: &str) -> bool {
if self.include_patterns.is_empty() {
return true;
}
self.include_patterns
.iter()
.any(|pattern| matches_pattern(url, pattern))
}
#[inline]
#[must_use]
pub fn matches_exclude(&self, url: &str) -> bool {
self.exclude_patterns
.iter()
.any(|pattern| matches_pattern(url, pattern))
}
}
#[derive(Debug)]
#[must_use]
pub struct CrawlerConfigBuilder {
use_sitemap: bool,
sitemap_url: Option<String>,
seed_url: Url,
max_depth: u8,
max_pages: usize,
include_patterns: Vec<String>,
exclude_patterns: Vec<String>,
concurrency: usize,
delay_ms: u64,
user_agent: String,
timeout_secs: u64,
}
impl CrawlerConfigBuilder {
pub fn new(seed_url: Url) -> Self {
Self {
seed_url,
max_depth: 3,
max_pages: 100,
include_patterns: Vec::new(),
exclude_patterns: Vec::new(),
concurrency: 3,
delay_ms: 500,
user_agent: "rust-scraper/0.3.0 (Web Crawler)".to_string(),
timeout_secs: 30,
use_sitemap: false,
sitemap_url: None,
}
}
pub fn max_depth(mut self, depth: u8) -> Self {
self.max_depth = depth;
self
}
pub fn max_pages(mut self, pages: usize) -> Self {
self.max_pages = pages;
self
}
pub fn include_pattern(mut self, pattern: impl Into<String>) -> Self {
self.include_patterns.push(pattern.into());
self
}
pub fn include_patterns(mut self, patterns: Vec<String>) -> Self {
self.include_patterns.extend(patterns);
self
}
pub fn exclude_pattern(mut self, pattern: impl Into<String>) -> Self {
self.exclude_patterns.push(pattern.into());
self
}
pub fn exclude_patterns(mut self, patterns: Vec<String>) -> Self {
self.exclude_patterns.extend(patterns);
self
}
pub fn concurrency(mut self, level: usize) -> Self {
self.concurrency = level;
self
}
pub fn delay_ms(mut self, ms: u64) -> Self {
self.delay_ms = ms;
self
}
pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
self.user_agent = ua.into();
self
}
pub fn timeout_secs(mut self, secs: u64) -> Self {
self.timeout_secs = secs;
self
}
pub fn use_sitemap(mut self, use_sitemap: bool) -> Self {
self.use_sitemap = use_sitemap;
self
}
pub fn sitemap_url(mut self, url: impl Into<String>) -> Self {
self.sitemap_url = Some(url.into());
self
}
#[must_use]
pub fn build(self) -> CrawlerConfig {
CrawlerConfig {
seed_url: self.seed_url,
max_depth: self.max_depth,
max_pages: self.max_pages,
include_patterns: self.include_patterns,
exclude_patterns: self.exclude_patterns,
concurrency: self.concurrency,
delay_ms: self.delay_ms,
user_agent: self.user_agent,
timeout_secs: self.timeout_secs,
use_sitemap: self.use_sitemap,
sitemap_url: self.sitemap_url,
}
}
}
#[derive(Debug, Clone, Default)]
#[must_use]
#[non_exhaustive]
pub struct CrawlResult {
pub urls: Vec<DiscoveredUrl>,
pub total_pages: usize,
pub errors: usize,
}
impl CrawlResult {
pub fn new(urls: Vec<DiscoveredUrl>, total_pages: usize, errors: usize) -> Self {
Self {
urls,
total_pages,
errors,
}
}
pub fn empty() -> Self {
Self::default()
}
#[inline]
#[must_use]
pub fn is_empty(&self) -> bool {
self.urls.is_empty()
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum CrawlError {
#[error("network error: {message} (status: {status_code:?})")]
Network {
message: String,
status_code: Option<u16>,
},
#[error("HTTP error: {0}")]
Http(String),
#[error("invalid URL: {0}")]
InvalidUrl(String),
#[error("parse error: {0}")]
Parse(String),
#[error("rate limit exceeded")]
RateLimit,
#[error("maximum depth {max} exceeded at depth {current}")]
MaxDepthExceeded { current: u8, max: u8 },
#[error("maximum pages {max} exceeded")]
MaxPagesExceeded { max: usize },
#[error("URL excluded: {0}")]
UrlExcluded(String),
#[error("invalid content type: {0}")]
InvalidContentType(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("semaphore error: {0}")]
Semaphore(String),
#[error("internal error: {0}")]
Internal(String),
#[error("sitemap error: {0}")]
Sitemap(String),
}
#[inline]
#[must_use]
pub fn matches_pattern(url_str: &str, pattern: &str) -> bool {
let url = match Url::parse(url_str) {
Ok(u) => u,
Err(_) => return false, };
let host = match url.host_str() {
Some(h) => h,
None => return false, };
if pattern.is_empty() {
return true;
}
if pattern == "*" {
return true;
}
match pattern {
p if p.starts_with("*.") && p.ends_with("*") => {
let domain = &p[2..p.len() - 1]; let domain = domain.trim_end_matches('/');
host.ends_with(&format!(".{}", domain))
}
p if p.starts_with("*.") => {
let domain = &p[2..];
host.ends_with(&format!(".{}", domain))
}
p => host == p,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_discovered_url_new() {
let url = Url::parse("https://example.com/page").unwrap();
let parent = Url::parse("https://example.com/").unwrap();
let discovered = DiscoveredUrl::new(url, 1, parent, ContentType::Html);
assert_eq!(discovered.depth, 1);
assert_eq!(discovered.content_type, ContentType::Html);
}
#[test]
fn test_discovered_url_html() {
let url = Url::parse("https://example.com/page").unwrap();
let parent = Url::parse("https://example.com/").unwrap();
let discovered = DiscoveredUrl::html(url, 0, parent);
assert_eq!(discovered.depth, 0);
assert_eq!(discovered.content_type, ContentType::Html);
}
#[test]
fn test_crawler_config_builder() {
let seed = Url::parse("https://example.com").unwrap();
let config = CrawlerConfig::builder(seed)
.max_depth(5)
.max_pages(500)
.concurrency(5)
.delay_ms(1000)
.include_pattern("*.example.com/*".to_string())
.exclude_pattern("*/admin/*".to_string())
.build();
assert_eq!(config.max_depth, 5);
assert_eq!(config.max_pages, 500);
assert_eq!(config.concurrency, 5);
assert_eq!(config.delay_ms, 1000);
assert_eq!(config.include_patterns.len(), 1);
assert_eq!(config.exclude_patterns.len(), 1);
}
#[test]
fn test_crawler_config_default() {
let seed = Url::parse("https://example.com").unwrap();
let config = CrawlerConfig::new(seed);
assert_eq!(config.max_depth, 3);
assert_eq!(config.max_pages, 100);
assert_eq!(config.concurrency, 3);
assert_eq!(config.delay_ms, 500);
}
#[test]
fn test_crawl_result_empty() {
let result = CrawlResult::empty();
assert!(result.is_empty());
assert_eq!(result.total_pages, 0);
assert_eq!(result.errors, 0);
}
#[test]
fn test_crawl_result_new() {
let url = Url::parse("https://example.com").unwrap();
let parent = Url::parse("https://example.com/").unwrap();
let discovered = DiscoveredUrl::html(url, 0, parent);
let result = CrawlResult::new(vec![discovered], 1, 0);
assert!(!result.is_empty());
assert_eq!(result.total_pages, 1);
assert_eq!(result.errors, 0);
assert_eq!(result.urls.len(), 1);
}
#[test]
fn test_crawl_error_network_no_reqwest() {
let error = CrawlError::Network {
message: "timeout".to_string(),
status_code: Some(408),
};
assert!(error.to_string().contains("timeout"));
assert!(error.to_string().contains("408"));
}
#[test]
fn test_crawl_error_network_no_status() {
let error = CrawlError::Network {
message: "connection refused".to_string(),
status_code: None,
};
assert!(error.to_string().contains("connection refused"));
assert!(error.to_string().contains("None"));
}
#[test]
fn test_crawl_error_io() {
let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let error = CrawlError::from(io_error);
assert!(matches!(error, CrawlError::Io(_)));
assert!(error.to_string().contains("file not found"));
}
#[test]
fn test_crawl_error_semaphore() {
let error = CrawlError::Semaphore("permit lost".to_string());
assert!(error.to_string().contains("permit lost"));
}
#[test]
fn test_crawl_error_internal() {
let error = CrawlError::Internal("something went wrong".to_string());
assert!(error.to_string().contains("something went wrong"));
}
#[test]
fn test_crawl_error_display_all_variants() {
let error = CrawlError::InvalidUrl("bad-url".to_string());
assert!(error.to_string().contains("bad-url"));
let error = CrawlError::Parse("html parse failed".to_string());
assert!(error.to_string().contains("html parse failed"));
let error = CrawlError::RateLimit;
assert_eq!(error.to_string(), "rate limit exceeded");
let error = CrawlError::MaxDepthExceeded { current: 5, max: 3 };
assert_eq!(error.to_string(), "maximum depth 3 exceeded at depth 5");
let error = CrawlError::MaxPagesExceeded { max: 100 };
assert_eq!(error.to_string(), "maximum pages 100 exceeded");
let error = CrawlError::UrlExcluded("https://evil.com".to_string());
assert!(error.to_string().contains("evil.com"));
let error = CrawlError::InvalidContentType("image/png".to_string());
assert!(error.to_string().contains("image/png"));
}
#[test]
fn test_matches_pattern_ssrf_bypass_attempt() {
assert!(!matches_pattern(
"https://evil.com/?q=example.com/path",
"*.example.com/*"
));
assert!(!matches_pattern(
"https://attacker.com/?redirect=example.com/admin",
"*.example.com/*"
));
assert!(!matches_pattern(
"https://malicious.com/redirect?url=example.com/secret",
"*.example.com/*"
));
}
#[test]
fn test_matches_pattern_real_subdomain() {
assert!(matches_pattern(
"https://blog.example.com/post",
"*.example.com/*"
));
assert!(matches_pattern(
"https://sub.example.com/page",
"*.example.com"
));
assert!(matches_pattern(
"https://deep.sub.example.com/page",
"*.example.com/*"
));
}
#[test]
fn test_matches_pattern_with_port() {
assert!(matches_pattern(
"https://blog.example.com:8080/path",
"*.example.com/*"
));
assert!(matches_pattern(
"https://blog.example.com:443/post",
"*.example.com/*"
));
}
#[test]
fn test_matches_pattern_ipv4() {
assert!(matches_pattern(
"http://192.168.1.1:8080/path",
"192.168.1.1"
));
}
#[test]
fn test_matches_pattern_ipv6() {
assert!(matches_pattern("http://[::1]:8080/path", "[::1]"));
}
#[test]
fn test_matches_pattern_invalid_url() {
assert!(!matches_pattern("not-a-url", "*.example.com/*"));
assert!(!matches_pattern("://missing-scheme.com", "*"));
assert!(!matches_pattern("", "*"));
}
#[test]
fn test_matches_pattern_wildcard() {
assert!(matches_pattern("https://example.com/page", "*"));
assert!(matches_pattern("https://any.domain.com/page", "*"));
}
#[test]
fn test_matches_pattern_empty() {
assert!(matches_pattern("https://example.com", ""));
}
#[test]
fn test_matches_pattern_no_match() {
assert!(!matches_pattern("https://other.com/page", "example.com"));
assert!(!matches_pattern("https://evil.com/page", "*.example.com/*"));
}
#[test]
fn test_matches_pattern_exact_host() {
assert!(matches_pattern("https://example.com/page", "example.com"));
assert!(!matches_pattern(
"https://sub.example.com/page",
"example.com"
));
}
#[test]
fn test_matches_pattern_prefix_wildcard() {
assert!(matches_pattern(
"https://blog.example.com/admin/users",
"*.example.com/*"
));
assert!(matches_pattern(
"https://admin.example.com/users",
"*.example.com/*"
));
assert!(!matches_pattern(
"https://example.com/admin/users",
"*.example.com/*"
));
}
#[test]
fn test_matches_pattern_slash_wildcard() {
assert!(matches_pattern(
"https://blog.example.com/admin/users",
"*.example.com/*"
));
assert!(matches_pattern(
"https://admin.example.com/users",
"*.example.com/*"
));
assert!(!matches_pattern(
"https://example.com/admin/users",
"*.example.com/*"
));
}
}