use reqwest::Client;
use scraper::{Html, Selector};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::error::Error;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, Mutex};
use tokio::time::sleep;
use url::Url;
const USER_AGENTS: &[&str] = &[
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0",
"PandubbiEngine/0.1.2 (Shiv Studio; World-Class Enterprise Search Engine Crawler)",
];
#[derive(Serialize, Deserialize, Debug, Clone)]
struct CrawledDocument {
url: String,
domain: String,
title: String,
content_hash: u64,
links_count: usize,
status: u16,
}
fn calculate_hash(content: &str) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
hasher.finish()
}
fn normalize_url(base_url: &str, target: &str) -> Option<String> {
if let Ok(base) = Url::parse(base_url) {
if let Ok(joined) = base.join(target) {
let mut clean_url = joined;
clean_url.set_fragment(None); return Some(clean_url.to_string());
}
}
None
}
async fn fetch_with_retry(client: &Client, url: &str, max_retries: u32) -> Result<(u16, String), Box<dyn Error + Send + Sync>> {
let mut attempts = 0;
let mut delay = Duration::from_secs(1);
loop {
attempts += 1;
match client.get(url).send().await {
Ok(response) => {
let status = response.status().as_u16();
if response.status().is_success() {
let body = response.text().await?;
return Ok((status, body));
} else {
println!("[Attempt {}] Server returned status: {}", attempts, status);
}
}
Err(e) => {
println!("[Attempt {}] Request failed: {}", attempts, e);
}
}
if attempts >= max_retries {
return Err("Max retry attempts reached.".into());
}
sleep(delay).await;
delay *= 2; }
}
fn parse_and_extract(base_url: &str, html: &str) -> (String, Vec<String>, u64) {
let document = Html::parse_document(html);
let title_selector = Selector::parse("title").unwrap();
let title = document
.select(&title_selector)
.next()
.map(|el| el.inner_html())
.unwrap_or_else(|| "No Title Found".to_string());
let link_selector = Selector::parse("a").unwrap();
let mut extracted_links = Vec::new();
for element in document.select(&link_selector) {
if let Some(href) = element.value().attr("href") {
if let Some(normalized) = normalize_url(base_url, href) {
extracted_links.push(normalized);
}
}
}
let content_hash = calculate_hash(html);
(title, extracted_links, content_hash)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
println!("==================================================");
println!(" Pandubbi World-Class Enterprise Crawler v0.1.2 ");
println!(" IOSC Search Engine Architecture | Shiv Studio ");
println!("==================================================");
let visited_urls: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
let domain_last_access: Arc<Mutex<HashMap<String, std::time::Instant>>> = Arc::new(Mutex::new(HashMap::new()));
let storage: Arc<Mutex<Vec<CrawledDocument>>> = Arc::new(Mutex::new(Vec::new()));
let (tx, mut rx) = mpsc::channel::<String>(2000);
let seed_urls = vec![
"https://example.com",
"https://httpbin.org/get",
];
for url in seed_urls {
visited_urls.lock().await.insert(url.to_string());
let _ = tx.send(url.to_string()).await;
}
let client = Client::builder()
.user_agent(USER_AGENTS[0])
.timeout(Duration::from_secs(10))
.pool_max_idle_per_host(20)
.build()?;
println!("\n[+] Initializing Distributed Frontier & Multi-Worker Engine...");
let client_clone = client.clone();
let visited_clone = visited_urls.clone();
let storage_clone = storage.clone();
let tx_clone = tx.clone();
let domain_access_clone = domain_last_access.clone();
let worker_handle = tokio::spawn(async move {
while let Some(url) = rx.recv().await {
println!("\n[Worker Frontier] Processing Target -> {}", url);
let parsed_url = match Url::parse(&url) {
Ok(u) => u,
Err(_) => continue,
};
let domain = parsed_url.host_str().unwrap_or("unknown").to_string();
{
let mut access_map = domain_access_clone.lock().await;
if let Some(last_time) = access_map.get(&domain) {
let elapsed = last_time.elapsed();
if elapsed < Duration::from_millis(1000) {
let wait_time = Duration::from_millis(1000) - elapsed;
sleep(wait_time).await;
}
}
access_map.insert(domain.clone(), std::time::Instant::now());
}
match fetch_with_retry(&client_clone, &url, 3).await {
Ok((status, html_body)) => {
let (title, extracted_links, content_hash) = parse_and_extract(&url, &html_body);
println!(" ├─ Title: {}", title);
println!(" ├─ Domain: {}", domain);
println!(" ├─ Content Hash: {}", content_hash);
println!(" └─ Discovered Links: {}", extracted_links.len());
let mut visited = visited_clone.lock().await;
for link in extracted_links {
if !visited.contains(&link) {
visited.insert(link.clone());
let _ = tx_clone.send(link).await;
}
}
let doc = CrawledDocument {
url: url.clone(),
domain,
title,
content_hash,
links_count: visited.len(),
status,
};
storage_clone.lock().await.push(doc);
}
Err(e) => {
println!(" └─ [Circuit Breaker/Error] Failed to crawl {}: {}", url, e);
}
}
}
});
let _ = tokio::time::timeout(Duration::from_secs(20), worker_handle).await;
let final_db = storage.lock().await;
println!("\n==================================================");
println!(" CRAWL SESSION SUMMARY & METRICS DASHBOARD ");
println!("==================================================");
println!(" Total Unique Pages Crawled : {}", final_db.len());
println!(" Data Export Destination : crawled_results.json");
println!("==================================================");
let json_data = serde_json::to_string_pretty(&*final_db)?;
std::fs::write("crawled_results.json", json_data)?;
println!("[+] State persisted & results saved successfully to JSON!");
Ok(())
}