mod search;
mod worker;
mod extractor;
mod legal;
mod cache;
mod clustering;
mod semantic_extractor;
mod api;
mod auth;
mod schedule_config;
mod scheduler;
mod benchmark;
use anyhow::{Result, anyhow};
use clap::Parser;
use dialoguer::{theme::ColorfulTheme, Input, Select, MultiSelect};
use url::Url;
use crate::worker::{PlaywrightWorker, ScrapingEngine, ScrapeOptions, ScrapeResponse};
use indicatif::{ProgressBar, ProgressStyle, MultiProgress};
use std::time::Duration;
use std::fs;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
input: Option<String>,
#[arg(short, long)]
daemon: bool,
#[arg(short = 'v', long)]
interactive: bool,
#[arg(short = 'e', long)]
extract: Option<String>,
#[arg(short, long)]
benchmark: bool,
}
fn print_banner() {
let banner = r#"
__ __
/ /_ ____ _ __ __ _____ / /_ _____ _____ _____ ____ _ ____ ___ _____
/ __ \ / __ `// / / // ___// __// ___// ___// __ `// __ `// __ \ / _ \ / ___/
/ /_/ // /_/ // /_/ /(__ )/ /_ / / / /__ / /_/ // /_/ // /_/ // __// /
/_.___/ \__, / \__,_//____/ \__/ /_/ \___/ \__,_/ \__,_// .___/ \___//_/
/____/ /_/
"#;
println!("{}", banner);
println!("--- Ingesta Robusta, Legal y Semántica (v0.1.0) ---\n");
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
if args.benchmark {
print_banner();
benchmark::run_benchmark()?;
return Ok(());
}
if args.daemon {
print_banner();
println!("⚙️ Iniciando bgustscraper en modo Daemon + Servidor API...");
tokio::spawn(async {
if let Err(e) = scheduler::run_scheduler_daemon().await {
eprintln!("⚠️ Error crítico en el planificador daemon: {}", e);
}
});
api::start_api_server(8080).await?;
return Ok(());
}
print_banner();
let mut interactive_args = args;
loop {
let input_val = match interactive_args.input.take() {
Some(i) => i,
None => {
Input::<String>::with_theme(&ColorfulTheme::default())
.with_prompt("🔍 Introduce una URL o un Prompt de búsqueda")
.interact_text()?
}
};
match process_input(&input_val, interactive_args.interactive, interactive_args.extract.as_deref()).await {
Ok(_) => {
let retry = dialoguer::Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("¿Deseas realizar otra búsqueda?")
.default(false)
.interact()?;
if !retry { break; }
}
Err(e) => {
println!("\n❌ Lo siento, no he podido conseguir el documento. {}", e);
let retry = dialoguer::Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("¿Quieres intentarlo de nuevo con alguna otra búsqueda?")
.default(true)
.interact()?;
if !retry { break; }
}
}
}
Ok(())
}
async fn process_input(input: &str, interactive: bool, extract_prompt: Option<&str>) -> Result<()> {
let multi = MultiProgress::new();
let pb = multi.add(ProgressBar::new_spinner());
pb.set_style(ProgressStyle::default_spinner()
.tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ ")
.template("{spinner:.green} {msg}")?);
if interactive {
pb.println("🖥️ [MODO VISUAL] Preparando ventana de Chromium...");
}
pb.set_message("Iniciando motores...");
pb.enable_steady_tick(Duration::from_millis(100));
let mut worker = match PlaywrightWorker::new(!interactive, ScrapingEngine::Playwright) {
Ok(w) => w,
Err(e) => {
pb.finish_with_message("❌ Error al iniciar Playwright.");
return Err(e);
}
};
let cache = cache::LocalCache::new()?;
let target_urls = if is_url(input) {
pb.set_message(format!("Analizando URL directa: {}", input));
vec![input.to_string()]
} else {
pb.set_message(format!("Buscando fuentes para: '{}'...", input));
let urls = search::search_duckduckgo(input).await?;
if urls.is_empty() {
pb.finish_with_message("⚠️ No se encontraron resultados para el prompt.");
worker.exit()?;
return Err(anyhow!("No se encontraron fuentes para el prompt."));
}
pb.set_message(format!("Se encontraron {} fuentes potenciales.", urls.len()));
pb.suspend(|| {
let mut items = vec!["Todas las fuentes".to_string()];
items.extend(urls.clone());
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Selecciona una fuente")
.items(&items)
.default(0)
.interact()?;
if selection == 0 {
Ok::<Vec<String>, anyhow::Error>(urls)
} else {
Ok(vec![urls[selection - 1].clone()])
}
})?
};
let mut found_any = false;
for url in target_urls {
pb.set_message(format!("🚀 Navegando a: {}", url));
let cache_key = format!("scrape:{}", url);
let response = match cache.get(&cache_key) {
Ok(Some(cached_json)) => {
pb.set_message("⚡ Recuperando datos de caché persistente (redb)...");
serde_json::from_str::<ScrapeResponse>(&cached_json)?
}
_ => {
pb.set_message(format!("📡 Conectando con {}...", url));
tokio::time::sleep(Duration::from_millis(500)).await;
let scrape_opts = ScrapeOptions {
deep_scan: false,
semantic_embeddings: Some(true),
text: None,
prompt: None,
};
let mut resp = worker.scrape(&url, Some(scrape_opts))?;
if resp.status == "error" || resp.html.as_ref().map_or(true, |h| h.contains("cf-challenge") || h.contains("Checking your browser")) {
pb.set_message("🔄 Playwright bloqueado. Intentando Motor 2 (Cloudscraper)...");
worker.exit()?;
worker = PlaywrightWorker::new(true, ScrapingEngine::Cloudscraper)?;
resp = worker.scrape(&url, None)?;
}
if resp.status == "success" {
if let Ok(resp_json) = serde_json::to_string(&resp) {
let _ = cache.set(&cache_key, &resp_json);
}
}
resp
}
};
if response.status == "success" {
let mut html = response.html.clone().unwrap_or_default();
let markdown = response.markdown.clone().unwrap_or_default();
pb.set_message("🧩 Analizando estructura del sitio...");
tokio::time::sleep(Duration::from_millis(800)).await;
pb.set_message("⚖️ Consultando cumplimiento legal...");
let legal_links = legal::find_legal_links(&html, &url);
let mut is_allowed = true;
if !legal_links.is_empty() {
pb.set_message(format!("📖 Leyendo términos en: {}", legal_links[0]));
let legal_resp = worker.scrape(&legal_links[0], None)?;
if let Some(legal_html) = legal_resp.html {
let status = legal::analyze_legal_text(&legal_html).await?;
pb.set_message(format!("✅ Análisis Legal: {}", status.reasoning));
is_allowed = status.allowed;
}
}
if !is_allowed {
pb.set_message("🚫 Abortando por restricciones legales.");
continue;
}
let compressed_text = if let (Some(sentences), Some(embeddings)) = (&response.sentences, &response.embeddings) {
pb.set_message("🧠 Comprimiendo contenido semánticamente (K-Means)...");
let summary_sentences = clustering::compress_text_by_clustering(sentences, embeddings, 12);
pb.set_message("✅ Contenido comprimido semánticamente.");
summary_sentences.join("\n")
} else {
markdown.chars().take(2000).collect()
};
if let Some(prompt) = extract_prompt {
pb.set_message("🔮 Ejecutando consulta de extracción semántica estructurada...");
let extract_opts = ScrapeOptions {
deep_scan: false,
semantic_embeddings: None,
text: Some(compressed_text.clone()),
prompt: Some(prompt.to_string()),
};
let extract_resp = worker.scrape("extract", Some(extract_opts))?;
if extract_resp.status == "success" {
if let Some(data) = extract_resp.data {
pb.finish_with_message("✨ ¡Extracción semántica exitosa!");
println!("\n📊 Datos extraídos en formato estructurado JSON:");
println!("{}", serde_json::to_string_pretty(&data)?);
fs::create_dir_all("downloads")?;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let filename = format!("downloads/extraction_{}.json", timestamp);
fs::write(&filename, serde_json::to_string_pretty(&data)?)?;
println!("\n💾 Archivo guardado con éxito en: {}", filename);
found_any = true;
break;
}
} else {
pb.finish_with_message("❌ Error en extracción.");
return Err(anyhow!("La extracción falló: {}", extract_resp.message.unwrap_or_default()));
}
}
pb.set_message("🔎 Escaneando en busca de documentos...");
let mut docs = extractor::find_documents(&html, &url);
if docs.is_empty() && matches!(worker.engine, ScrapingEngine::Playwright) {
pb.set_message("🕵️ No se hallaron documentos directos. Activando Deep Scan...");
let deep_resp = worker.scrape(&url, Some(ScrapeOptions {
deep_scan: true,
semantic_embeddings: None,
text: None,
prompt: None
}))?;
if deep_resp.status == "success" {
html = deep_resp.html.unwrap();
docs = extractor::find_documents(&html, &url);
}
}
pb.set_message("🧠 Analizando texto semánticamente para detectar más documentos...");
if let Ok(semantic_docs) = semantic_extractor::extract_semantic_documents(&mut worker, &compressed_text, &url) {
if !semantic_docs.is_empty() {
pb.set_message(format!("✨ Encontrados {} documentos por vía semántica.", semantic_docs.len()));
for sem_doc in semantic_docs {
if !docs.iter().any(|d| d.url == sem_doc.url) {
docs.push(extractor::ExtractedDocument {
url: sem_doc.url,
extension: sem_doc.extension,
name: format!("{} [Categoría: {}] - Resumen: {}", sem_doc.name, sem_doc.category, sem_doc.summary),
});
}
}
}
}
if docs.is_empty() {
if html.contains("cf-challenge") || html.contains("Checking your browser") || html.contains("captcha") {
pb.set_message("⚠️ Bloqueo de seguridad detectado.");
} else {
pb.set_message("📭 No se hallaron documentos.");
}
tokio::time::sleep(Duration::from_millis(2000)).await;
} else {
found_any = true;
pb.set_message(format!("✨ ¡Éxito! {} documentos encontrados.", docs.len()));
let selections = pb.suspend(|| {
println!("\n📂 Documentos disponibles en {}:", url);
let mut choices = Vec::new();
for (i, doc) in docs.iter().enumerate() {
choices.push(format!("{}. {}", i + 1, doc.name));
}
MultiSelect::with_theme(&ColorfulTheme::default())
.with_prompt("Marca los archivos que deseas descargar")
.items(&choices)
.interact()
})?;
if !selections.is_empty() {
fs::create_dir_all("downloads")?;
for index in selections {
let doc = &docs[index];
let clean_filename = doc.url.split('/').last()
.unwrap_or("descarga.bin")
.split('?').next()
.unwrap_or("descarga.bin");
let dl_pb = multi.add(ProgressBar::new(0));
dl_pb.set_style(ProgressStyle::default_bar()
.template("{msg}\n{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes}")?
.progress_chars("#>-"));
dl_pb.set_message(format!("📥 Descargando: {}", clean_filename));
if let Err(e) = download_file_with_progress(&doc.url, clean_filename, &dl_pb).await {
dl_pb.finish_with_message(format!("❌ Error: {}", e));
} else {
dl_pb.finish_with_message(format!("✅ Descargado: {}", clean_filename));
}
}
}
}
} else {
pb.set_message(format!("❌ Error: {}", response.message.unwrap_or_default()));
}
}
pb.set_message("Finalizando sesión...");
worker.exit()?;
if !found_any {
pb.finish_and_clear();
return Err(anyhow!("No se encontraron documentos en las fuentes analizadas."));
}
pb.finish_with_message("🏁 ¡Todo listo! Revisa la carpeta 'downloads'.");
Ok(())
}
fn is_url(input: &str) -> bool {
Url::parse(input).is_ok()
}
async fn download_file_with_progress(url: &str, filename: &str, pb: &ProgressBar) -> Result<()> {
let mut fixed_url = url.to_string();
if fixed_url.contains(".tsj.gov.ve") {
fixed_url = fixed_url.replace(".tsj.gov.ve", ".tsj.gob.ve");
}
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
.build()?;
let res = client.get(&fixed_url).send().await?;
if !res.status().is_success() {
return Err(anyhow!("Error del servidor: {}", res.status()));
}
let total_size = res.content_length().unwrap_or(0);
pb.set_length(total_size);
let path = format!("downloads/{}", filename);
let mut file = std::fs::File::create(path)?;
let mut stream = res.bytes_stream();
use futures_util::StreamExt;
while let Some(item) = stream.next().await {
let chunk = item?;
use std::io::Write;
file.write_all(&chunk)?;
pb.inc(chunk.len() as u64);
}
Ok(())
}