use anyhow::Context;
use clap::Parser;
use rust_scraper::{
adapters::tui,
application::{discover_urls_for_tui, scrape_urls_for_tui},
export_factory, validate_and_parse_url, Args, CrawlerConfig, ExportFormat, ScraperConfig,
UserAgentCache,
};
use std::path::PathBuf;
use tracing::{info, warn};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
let log_level = match args.verbose {
0 => "info",
1 => "debug",
_ => "trace",
};
rust_scraper::config::init_logging(log_level);
info!("🚀 Rust Scraper v0.4.0 - Clean Architecture + TUI");
info!("📌 Target: {}", args.url);
info!("📁 Output: {}", args.output.display());
info!("🔄 Loading user agents (cache check)...");
let user_agents = UserAgentCache::load().await;
info!(
"✅ User agent loaded: {} agents available",
user_agents.len()
);
let parsed_url = validate_and_parse_url(&args.url).context("Invalid URL provided")?;
info!("✅ URL validated: {}", parsed_url);
let scraper_config = ScraperConfig {
download_images: args.download_images,
download_documents: args.download_documents,
output_dir: args.output.clone(),
max_file_size: Some(50 * 1024 * 1024), scraper_concurrency: args.concurrency.resolve(), };
if scraper_config.download_images {
info!("🖼️ Image download: ENABLED");
}
if scraper_config.download_documents {
info!("📄 Document download: ENABLED");
}
let user_agent = get_random_user_agent_from_pool(&user_agents);
let crawler_config = CrawlerConfig::builder(parsed_url.clone())
.max_depth(2)
.max_pages(args.max_pages)
.concurrency(args.concurrency.resolve()) .delay_ms(args.delay_ms)
.user_agent(user_agent)
.timeout_secs(30)
.use_sitemap(args.use_sitemap)
.sitemap_url(args.sitemap_url.clone().unwrap_or_default())
.build();
info!("🔍 Discovering URLs...");
let discovered_urls = discover_urls_for_tui(&args.url, &crawler_config)
.await
.context("URL discovery failed")?;
info!("✅ Found {} URLs", discovered_urls.len());
if discovered_urls.is_empty() {
warn!("⚠️ No URLs discovered, nothing to scrape");
return Ok(());
}
let urls_to_scrape = if args.interactive {
info!("🎮 Starting interactive TUI selector...");
match tui::run_selector(&discovered_urls).await {
Ok(selected) => {
info!("✅ User selected {} URLs", selected.len());
if selected.is_empty() {
info!("ℹ️ No URLs selected, exiting");
return Ok(());
}
selected
}
Err(tui::TuiError::Interrupted) => {
info!("ℹ️ User interrupted TUI selector, exiting");
return Ok(());
}
Err(e) => {
return Err(anyhow::anyhow!("TUI error: {}", e));
}
}
} else {
info!(
"📡 Headless mode: will scrape all {} URLs",
discovered_urls.len()
);
discovered_urls
};
if args.export_format == ExportFormat::Zvec
&& !rust_scraper::infrastructure::export::zvec_exporter::ZvecExporter::is_available()
{
warn!(
"⚠️ Zvec format requested but not available. Enable with: cargo build --features zvec"
);
return Err(anyhow::anyhow!(
"Zvec feature not enabled. Use: cargo build --features zvec"
));
}
let state_store = if args.resume {
info!("🎯 Resume mode enabled - tracking processed URLs");
let state_dir = args.state_dir.unwrap_or_else(|| {
let cache_base = std::env::var("XDG_CACHE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".cache")
});
cache_base.join("rust-scraper").join("state")
});
let domain = export_factory::domain_from_url(&args.url);
info!("📁 State store domain: {}", domain);
Some(export_factory::create_state_store(state_dir, &domain)?)
} else {
None
};
let urls_to_scrape = if args.resume {
if let Some(store) = state_store.as_ref() {
let state = store.load_or_default()?;
let original_count = urls_to_scrape.len();
let filtered: Vec<_> = urls_to_scrape
.into_iter()
.filter(|url| {
let should_skip = store.is_processed(&state, url.as_str());
if should_skip {
info!("⏭️ Skipping already processed: {}", url);
}
!should_skip
})
.collect();
let skipped_count = original_count - filtered.len();
info!(
"🔄 Resume mode: {} URLs already processed, {} new URLs to scrape",
skipped_count,
filtered.len()
);
filtered
} else {
urls_to_scrape
}
} else {
urls_to_scrape
};
info!("🕷️ Scraping {} URLs...", urls_to_scrape.len());
let all_results = scrape_urls_for_tui(&urls_to_scrape, &scraper_config)
.await
.context("Scraping failed")?;
if all_results.is_empty() {
warn!("⚠️ No content extracted from any URL");
return Ok(());
}
info!(
"✅ Scraping completed: {} elements extracted",
all_results.len()
);
info!("💾 Exporting results (format: {:?})...", args.export_format);
let processed_urls = export_factory::process_results(
&all_results,
args.output.clone(),
args.export_format,
"export",
state_store.as_ref(),
args.resume,
)?;
let total_assets: usize = all_results.iter().map(|r| r.assets.len()).sum();
if total_assets > 0 {
info!(
"📦 Total assets downloaded: {} (images and documents)",
total_assets
);
}
info!("🎉 Pipeline completed successfully!");
info!("📊 Files generated: {}", args.output.display());
info!("📈 Total URLs processed: {}", urls_to_scrape.len());
if args.resume {
info!(
"🔄 Resume mode: processed {} new URLs",
processed_urls.len()
);
}
Ok(())
}
fn get_random_user_agent_from_pool(user_agents: &[String]) -> String {
use rand::Rng;
let mut rng = rand::thread_rng();
let index = rng.gen_range(0..user_agents.len());
user_agents[index].clone()
}