feat: update dependencies and enhance chat functionality

- Added new Radix UI components including checkbox, label, popover, slider, switch, and tooltip to improve UI interactions.
- Integrated react-syntax-highlighter for better code rendering in chat messages.
- Updated package.json and pnpm-lock.yaml to include new dependencies and ensure compatibility.
- Enhanced chat input to support web search toggling, improving user experience during message composition.
- Implemented auto-saving of chat sessions and improved session management features.
- Refactored chat message rendering to clean up metadata and improve display quality.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-20 04:26:35 -03:00
1 parent 032722e21b
commit 8834ef2b4f
55 files changed
+20030 -159

No files matched your search

+1543 -48
View File
File diff suppressed because it is too large. Load diff
+18 -1
View File
@@ -23,5 +23,22 @@ serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.9.2", features = [] }
tauri-plugin-log = "2"
reqwest = { version = "0.12", features = ["json"] }
tauri-plugin-shell = "2"
tauri-plugin-notification = "2"
reqwest = { version = "0.12", features = ["json", "stream"] }
sysinfo = "0.36.1"
uuid = { version = "1.10", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
tokio = { version = "1", features = ["full"] }
headless_chrome = "1.0"
scraper = "0.18"
readability = "0.3"
html2text = "0.5"
anyhow = "1.0"
url = "2.5"
urlencoding = "2.1"
zip = "0.6"
rand = "0.8"
tokio-cron-scheduler = "0.9"
futures-util = "0.3"
regex = "1.10"
+1284 -4
View File
File diff suppressed because it is too large. Load diff
+153
View File
@@ -0,0 +1,153 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Mensagem para o Ollama API
#[derive(Debug, Serialize)]
struct OllamaMessage {
role: String,
content: String,
}
/// Request para chat do Ollama
#[derive(Debug, Serialize)]
struct OllamaChatRequest {
model: String,
messages: Vec<OllamaMessage>,
stream: bool,
}
/// Response do Ollama (streaming)
#[derive(Debug, Deserialize)]
struct OllamaChunk {
message: Option<OllamaMessageResponse>,
done: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct OllamaMessageResponse {
content: String,
}
/// Cliente Ollama headless (para execução em background)
pub struct OllamaClient {
base_url: String,
client: reqwest::Client,
}
impl OllamaClient {
/// Cria novo cliente Ollama
pub fn new(base_url: Option<String>) -> Self {
let base = base_url.unwrap_or_else(|| "http://localhost:11434".to_string());
Self {
base_url: base,
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300)) // 5 minutos timeout
.build()
.expect("Failed to create HTTP client"),
}
}
/// Verifica se o Ollama está rodando
pub async fn check_connection(&self) -> Result<(), String> {
let url = format!("{}/api/tags", self.base_url);
let response = self.client
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to connect to Ollama: {}", e))?;
if response.status().is_success() {
Ok(())
} else {
Err(format!("Ollama returned status: {}", response.status()))
}
}
/// Envia prompt para o Ollama e retorna resposta completa (não streaming)
pub async fn query_ollama_headless(
&self,
model: &str,
system_prompt: Option<&str>,
user_prompt: &str,
) -> Result<String, String> {
// Verificar conexão primeiro
self.check_connection().await?;
let mut messages = Vec::new();
// Adicionar system prompt se fornecido
if let Some(sys_prompt) = system_prompt {
messages.push(OllamaMessage {
role: "system".to_string(),
content: sys_prompt.to_string(),
});
}
// Adicionar mensagem do usuário
messages.push(OllamaMessage {
role: "user".to_string(),
content: user_prompt.to_string(),
});
let request = OllamaChatRequest {
model: model.to_string(),
messages,
stream: true, // Streaming para economizar memória
};
let url = format!("{}/api/chat", self.base_url);
let mut response = self.client
.post(&url)
.json(&request)
.send()
.await
.map_err(|e| format!("Failed to send request to Ollama: {}", e))?;
if !response.status().is_success() {
return Err(format!("Ollama returned status: {}", response.status()));
}
// Ler stream e acumular resposta
let mut full_response = String::new();
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("Failed to read chunk: {}", e))?;
let text = String::from_utf8_lossy(&chunk);
// Processar cada linha (Ollama envia JSON por linha)
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<OllamaChunk>(line) {
Ok(chunk_data) => {
if let Some(message) = chunk_data.message {
full_response.push_str(&message.content);
}
// Se done, parar
if chunk_data.done == Some(true) {
break;
}
}
Err(e) => {
log::debug!("Failed to parse Ollama chunk: {} - Line: {}", e, line);
// Continuar mesmo com erro de parse
}
}
}
}
if full_response.is_empty() {
return Err("Empty response from Ollama".to_string());
}
Ok(full_response.trim().to_string())
}
}
+163
View File
@@ -0,0 +1,163 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use tauri::{AppHandle, Manager};
use uuid::Uuid;
/// Tipos de ações que uma task pode executar
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum TaskAction {
/// Pesquisar na web e resumir com IA
SearchAndSummarize {
query: String,
model: String,
max_results: usize,
},
/// Apenas enviar notificação (ping)
JustPing {
message: String,
},
/// Executar prompt customizado no Ollama
CustomPrompt {
prompt: String,
model: String,
},
}
/// Estrutura de uma Task agendada
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SentinelTask {
pub id: String,
pub label: String,
pub cron_schedule: String, // Ex: "0 8 * * *" (Todo dia às 8h)
pub action: TaskAction,
pub enabled: bool,
pub last_run: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Estado do scheduler (gerenciado pelo Tauri)
pub type SchedulerState = Arc<Mutex<SchedulerService>>;
/// Serviço de agendamento de tarefas
pub struct SchedulerService {
tasks: HashMap<String, SentinelTask>,
tasks_file: PathBuf,
app_handle: Option<AppHandle>,
}
impl SchedulerService {
/// Cria novo serviço de scheduler
pub fn new(app_handle: AppHandle) -> Result<Self, String> {
let app_data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
let tasks_file = app_data_dir.join("tasks.json");
// Carregar tasks existentes
let tasks = if tasks_file.exists() {
match fs::read_to_string(&tasks_file) {
Ok(content) => {
match serde_json::from_str::<Vec<SentinelTask>>(&content) {
Ok(loaded_tasks) => {
loaded_tasks
.into_iter()
.map(|task| (task.id.clone(), task))
.collect()
}
Err(e) => {
log::warn!("Failed to parse tasks.json: {}. Starting with empty tasks.", e);
HashMap::new()
}
}
}
Err(e) => {
log::warn!("Failed to read tasks.json: {}. Starting with empty tasks.", e);
HashMap::new()
}
}
} else {
HashMap::new()
};
Ok(Self {
tasks,
tasks_file,
app_handle: Some(app_handle),
})
}
/// Salva tasks no arquivo
fn save_tasks(&self) -> Result<(), String> {
let tasks_vec: Vec<&SentinelTask> = self.tasks.values().collect();
let json = serde_json::to_string_pretty(&tasks_vec)
.map_err(|e| format!("Failed to serialize tasks: {}", e))?;
// Escrever em arquivo temporário primeiro (atomic write)
let temp_file = self.tasks_file.with_extension("json.tmp");
fs::write(&temp_file, json)
.map_err(|e| format!("Failed to write temp tasks file: {}", e))?;
fs::rename(&temp_file, &self.tasks_file)
.map_err(|e| format!("Failed to rename temp file: {}", e))?;
Ok(())
}
/// Adiciona ou atualiza uma task
pub fn upsert_task(&mut self, task: SentinelTask) -> Result<(), String> {
self.tasks.insert(task.id.clone(), task);
self.save_tasks()?;
Ok(())
}
/// Remove uma task
pub fn remove_task(&mut self, id: &str) -> Result<(), String> {
self.tasks.remove(id);
self.save_tasks()?;
Ok(())
}
/// Lista todas as tasks
pub fn list_tasks(&self) -> Vec<SentinelTask> {
self.tasks.values().cloned().collect()
}
/// Obtém uma task por ID
pub fn get_task(&self, id: &str) -> Option<&SentinelTask> {
self.tasks.get(id)
}
/// Atualiza última execução de uma task
pub fn update_last_run(&mut self, id: &str, timestamp: DateTime<Utc>) -> Result<(), String> {
if let Some(task) = self.tasks.get_mut(id) {
task.last_run = Some(timestamp);
self.save_tasks()?;
}
Ok(())
}
/// Obtém tasks habilitadas
pub fn get_enabled_tasks(&self) -> Vec<&SentinelTask> {
self.tasks.values().filter(|t| t.enabled).collect()
}
}
/// Helper para obter diretório de tasks
pub fn get_tasks_file_path(app_handle: &AppHandle) -> Result<PathBuf, String> {
let app_data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
Ok(app_data_dir.join("tasks.json"))
}
+128
View File
@@ -0,0 +1,128 @@
use crate::scheduler::{SchedulerService, SchedulerState};
use crate::task_executor::execute_task;
use tokio_cron_scheduler::{Job, JobScheduler};
use std::sync::Arc;
use tauri::AppHandle;
use chrono::Utc;
/// Inicia o loop do scheduler
pub async fn start_scheduler_loop(
app_handle: AppHandle,
scheduler_state: SchedulerState,
_browser_state: Option<()>, // Não usado mais - browser será criado quando necessário
ollama_url: Option<String>,
) -> Result<(), String> {
let mut sched = JobScheduler::new()
.await
.map_err(|e| format!("Failed to create job scheduler: {}", e))?;
// Carregar tasks e agendar
reload_scheduled_tasks(
&mut sched,
&app_handle,
&scheduler_state,
ollama_url.clone(),
).await?;
// Iniciar scheduler em background
tokio::spawn(async move {
if let Err(e) = sched.start().await {
log::error!("Scheduler error: {}", e);
}
});
log::info!("Scheduler loop iniciado");
Ok(())
}
/// Recarrega tasks do scheduler
pub async fn reload_scheduled_tasks(
sched: &mut JobScheduler,
app_handle: &AppHandle,
scheduler_state: &SchedulerState,
ollama_url: Option<String>,
) -> Result<(), String> {
// Limpar jobs existentes
sched.shutdown().await.ok();
*sched = JobScheduler::new()
.await
.map_err(|e| format!("Failed to recreate scheduler: {}", e))?;
let scheduler = scheduler_state.lock().await;
let enabled_tasks = scheduler.get_enabled_tasks();
for task in enabled_tasks {
// Clonar valores ANTES de mover para a closure
let task_id_for_job = task.id.clone();
let task_id_for_log = task.id.clone();
let task_label_for_job = task.label.clone();
let task_label_for_log = task.label.clone();
let cron_expr = task.cron_schedule.clone();
let app_handle_clone = app_handle.clone();
let scheduler_clone = scheduler_state.clone();
let ollama_url_clone = ollama_url.clone();
// Criar job para esta task
let job = Job::new_async(cron_expr.as_str(), move |_uuid, _l| {
let task_id = task_id_for_job.clone();
let task_label = task_label_for_job.clone();
let app_handle = app_handle_clone.clone();
let scheduler = scheduler_clone.clone();
let ollama_url = ollama_url_clone.clone();
Box::pin(async move {
log::info!("Executando task agendada: {} ({})", task_label, task_id);
// Obter task atualizada
let task_opt = {
let sched = scheduler.lock().await;
sched.get_task(&task_id).cloned()
};
if let Some(task) = task_opt {
if !task.enabled {
log::info!("Task {} está desabilitada, pulando", task_id);
return;
}
// Obter browser - precisa acessar via app_handle
let browser_arc = {
// Criar browser diretamente se necessário
use crate::web_scraper::create_browser;
match create_browser() {
Ok(b) => Arc::new(b),
Err(e) => {
log::error!("Erro ao criar browser para task {}: {}", task_id, e);
return;
}
}
};
// Executar task
match execute_task(&task, app_handle.clone(), browser_arc, ollama_url).await {
Ok(_) => {
// Atualizar last_run
let mut sched = scheduler.lock().await;
let _ = sched.update_last_run(&task_id, Utc::now());
log::info!("Task {} executada com sucesso", task_id);
}
Err(e) => {
log::error!("Erro ao executar task {}: {}", task_id, e);
}
}
} else {
log::warn!("Task {} não encontrada", task_id);
}
})
})
.map_err(|e| format!("Failed to create job for task {}: {}", task_id_for_log, e))?;
sched.add(job).await
.map_err(|e| format!("Failed to add job to scheduler: {}", e))?;
log::info!("Task '{}' agendada com cron: {}", task_label_for_log, cron_expr);
}
Ok(())
}
+276
View File
@@ -0,0 +1,276 @@
use crate::scheduler::{SentinelTask, TaskAction};
use crate::ollama_client::OllamaClient;
use crate::web_scraper::search_and_scrape;
use crate::{Message, ChatSession, get_chats_dir};
use std::sync::Arc;
use std::fs;
use headless_chrome::Browser;
use chrono::Utc;
use tauri::AppHandle;
use tauri_plugin_notification::NotificationExt;
use sysinfo::System;
/// Executa uma task agendada
pub async fn execute_task(
task: &SentinelTask,
app_handle: AppHandle,
browser: Arc<Browser>,
ollama_url: Option<String>,
) -> Result<(), String> {
log::info!("Executando task: {} ({})", task.label, task.id);
let client = OllamaClient::new(ollama_url);
match &task.action {
TaskAction::SearchAndSummarize { query, model, max_results } => {
execute_search_and_summarize(
task,
query,
model,
*max_results,
&app_handle,
browser,
&client,
).await
}
TaskAction::JustPing { message } => {
execute_just_ping(task, message, &app_handle).await
}
TaskAction::CustomPrompt { prompt, model } => {
execute_custom_prompt(
task,
prompt,
model,
&app_handle,
&client,
).await
}
}
}
/// Executa pesquisa e resumo
async fn execute_search_and_summarize(
task: &SentinelTask,
query: &str,
model: &str,
max_results: usize,
app_handle: &AppHandle,
browser: Arc<Browser>,
ollama_client: &OllamaClient,
) -> Result<(), String> {
// 1. Buscar conteúdo na web
log::info!("Buscando conteúdo para: {}", query);
let scraped = search_and_scrape(query, max_results, browser, vec![])
.await
.map_err(|e| format!("Erro ao buscar conteúdo: {}", e))?;
if scraped.is_empty() {
return Err("Nenhum resultado encontrado na busca".to_string());
}
// 2. Combinar conteúdo em markdown
let web_context: String = scraped
.iter()
.map(|s| format!("---\nTítulo: {}\nURL: {}\n---\n\n{}", s.title, s.url, s.markdown))
.collect::<Vec<_>>()
.join("\n\n");
// 3. Criar prompt para o Ollama
let system_prompt = format!(
"Você é um assistente especializado em resumir e analisar informações da web.\n\
DATA ATUAL: {}\n\n\
Use as informações fornecidas abaixo para criar um resumo detalhado e útil.",
Utc::now().format("%d/%m/%Y %H:%M")
);
let user_prompt = format!(
"Com base nas informações abaixo sobre '{}', crie um resumo detalhado e estruturado.\n\n\
## CONTEXTO WEB\n{}\n\n\
Por favor, forneça:\n\
1. Resumo executivo (2-3 parágrafos)\n\
2. Principais pontos encontrados\n\
3. Conclusões ou insights relevantes",
query,
web_context
);
// 4. Enviar para Ollama
log::info!("Enviando para Ollama (modelo: {})", model);
let summary = ollama_client
.query_ollama_headless(model, Some(&system_prompt), &user_prompt)
.await
.map_err(|e| format!("Erro ao consultar Ollama: {}", e))?;
// 5. Salvar como sessão de chat
let session_id = uuid::Uuid::new_v4().to_string();
let now = Utc::now();
let messages = vec![
Message {
role: "user".to_string(),
content: format!("Pesquisa agendada: {}", query),
metadata: Some(serde_json::json!({
"task_id": task.id,
"task_label": task.label,
"sources_count": scraped.len(),
})),
},
Message {
role: "assistant".to_string(),
content: summary,
metadata: Some(serde_json::json!({
"task_id": task.id,
"sources": scraped.iter().map(|s| serde_json::json!({
"title": s.title,
"url": s.url,
})).collect::<Vec<_>>(),
})),
},
];
// Salvar sessão diretamente (helper function)
save_task_session_internal(
app_handle,
&session_id,
&format!("[Agendado] {}", task.label),
messages,
)?;
// 6. Enviar notificação
app_handle
.notification()
.builder()
.title("Pesquisa Agendada Concluída")
.body(&format!("{} está pronta! Verifique sua sessão de chat.", task.label))
.show()
.map_err(|e| format!("Erro ao enviar notificação: {}", e))?;
log::info!("Task {} executada com sucesso. Sessão salva: {}", task.id, session_id);
Ok(())
}
/// Executa apenas ping/notificação
async fn execute_just_ping(
task: &SentinelTask,
message: &str,
app_handle: &AppHandle,
) -> Result<(), String> {
app_handle
.notification()
.builder()
.title(&task.label)
.body(message)
.show()
.map_err(|e| format!("Erro ao enviar notificação: {}", e))?;
log::info!("Ping enviado para task: {}", task.id);
Ok(())
}
/// Helper para salvar sessão de task (sem usar State do Tauri)
fn save_task_session_internal(
app_handle: &AppHandle,
session_id: &str,
title: &str,
messages: Vec<Message>,
) -> Result<(), String> {
let chats_dir = get_chats_dir(app_handle)?;
let file_path = chats_dir.join(format!("{}.json", session_id));
let file_path = chats_dir.join(format!("{}.json", session_id));
let now = Utc::now();
// Try to load existing to keep created_at, or use now
let created_at = if file_path.exists() {
if let Ok(content) = fs::read_to_string(&file_path) {
if let Ok(session) = serde_json::from_str::<ChatSession>(&content) {
session.created_at
} else {
now
}
} else {
now
}
} else {
now
};
let platform = System::name().unwrap_or("Unknown".to_string());
let session = ChatSession {
id: session_id.to_string(),
title: title.to_string(),
messages,
created_at,
updated_at: now,
platform,
memory_context: Vec::new(),
};
let json = serde_json::to_string_pretty(&session)
.map_err(|e| format!("Failed to serialize session: {}", e))?;
// Escrever em arquivo temporário primeiro, depois renomear (atomic write)
let temp_path = file_path.with_extension("json.tmp");
fs::write(&temp_path, json)
.map_err(|e| format!("Failed to write temp session file: {}", e))?;
// Renomear atomicamente
fs::rename(&temp_path, &file_path)
.map_err(|e| format!("Failed to rename temp file to session file: {}", e))?;
Ok(())
}
/// Executa prompt customizado
async fn execute_custom_prompt(
task: &SentinelTask,
prompt: &str,
model: &str,
app_handle: &AppHandle,
ollama_client: &OllamaClient,
) -> Result<(), String> {
let response = ollama_client
.query_ollama_headless(model, None, prompt)
.await
.map_err(|e| format!("Erro ao consultar Ollama: {}", e))?;
// Salvar como sessão
let session_id = uuid::Uuid::new_v4().to_string();
let messages = vec![
Message {
role: "user".to_string(),
content: prompt.to_string(),
metadata: Some(serde_json::json!({
"task_id": task.id,
"task_label": task.label,
})),
},
Message {
role: "assistant".to_string(),
content: response,
metadata: Some(serde_json::json!({
"task_id": task.id,
})),
},
];
save_task_session_internal(
app_handle,
&session_id,
&format!("[Agendado] {}", task.label),
messages,
)?;
// Notificação
app_handle
.notification()
.builder()
.title("Task Executada")
.body(&format!("{} foi executada com sucesso!", task.label))
.show()
.map_err(|e| format!("Erro ao enviar notificação: {}", e))?;
Ok(())
}
+574
View File
@@ -0,0 +1,574 @@
use anyhow::Result;
use headless_chrome::{Browser, LaunchOptions};
use reqwest::header::USER_AGENT;
use scraper::{Html, Selector};
use std::sync::Arc;
use std::time::Duration;
use url::Url;
use rand::Rng;
use tokio::sync::Semaphore;
use regex::Regex;
/// Resultado da extração de conteúdo de uma URL
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
pub struct ScrapedContent {
pub title: String,
pub url: String,
pub content: String,
pub markdown: String,
}
/// Categoria de busca com sites curados
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
pub struct SearchCategory {
pub id: String,
pub name: String,
pub base_sites: Vec<String>,
pub enabled: bool,
}
/// Configuração completa de busca
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
pub struct SearchConfig {
#[serde(default = "default_max_concurrent")]
pub max_concurrent_tabs: usize,
#[serde(default = "default_total_sources")]
pub total_sources_limit: usize,
pub categories: Vec<SearchCategory>,
#[serde(default)]
pub user_custom_sites: Vec<String>,
#[serde(default)]
pub excluded_domains: Vec<String>,
}
fn default_max_concurrent() -> usize {
5
}
fn default_total_sources() -> usize {
40
}
/// Pool de User-Agents para rotação (evita bloqueios 429)
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 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0",
];
/// Retorna um User-Agent aleatório do pool
fn get_random_user_agent() -> &'static str {
let mut rng = rand::thread_rng();
let index = rng.gen_range(0..USER_AGENTS.len());
USER_AGENTS[index]
}
/// Busca no DuckDuckGo e retorna URLs dos resultados
pub async fn search_duckduckgo(query: &str, limit: usize) -> Result<Vec<String>> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
let url = format!("https://html.duckduckgo.com/html/?q={}",
urlencoding::encode(query));
// User-Agent rotativo para evitar bloqueios
let user_agent = get_random_user_agent();
log::debug!("Usando User-Agent: {}", user_agent);
let res = client
.get(&url)
.header(USER_AGENT, user_agent)
.send()
.await?
.text()
.await?;
let document = Html::parse_document(&res);
// Seletores para links orgânicos do DuckDuckGo
// Tenta múltiplos seletores para maior compatibilidade
let selectors = vec![
".result__a",
".web-result__link",
"a.result__a",
];
let mut links = Vec::new();
for selector_str in selectors {
if let Ok(selector) = Selector::parse(selector_str) {
for element in document.select(&selector).take(limit) {
if let Some(href) = element.value().attr("href") {
// DuckDuckGo usa redirecionamento, extrair URL real
if let Some(real_url) = extract_real_url(href) {
if !links.contains(&real_url) {
links.push(real_url);
if links.len() >= limit {
break;
}
}
}
}
}
if !links.is_empty() {
break;
}
}
}
Ok(links)
}
/// Extrai a URL real do redirecionamento do DuckDuckGo
fn extract_real_url(ddg_redirect: &str) -> Option<String> {
// DuckDuckGo usa formato: /l/?kh=-1&uddg=<URL_ENCODED>
if let Some(uddg_start) = ddg_redirect.find("uddg=") {
if let Some(encoded_url) = ddg_redirect.get(uddg_start + 5..) {
if let Some(ampersand) = encoded_url.find('&') {
if let Ok(decoded) = urlencoding::decode(&encoded_url[..ampersand]) {
return Some(decoded.to_string());
}
} else if let Ok(decoded) = urlencoding::decode(encoded_url) {
return Some(decoded.to_string());
}
}
}
// Se não for redirecionamento DDG, retorna como está (se for URL válida)
if ddg_redirect.starts_with("http://") || ddg_redirect.starts_with("https://") {
Some(ddg_redirect.to_string())
} else {
None
}
}
/// Extrai o domínio de uma URL
fn extract_domain(url: &str) -> Option<String> {
if let Ok(parsed) = Url::parse(url) {
if let Some(host) = parsed.host_str() {
return Some(host.to_string());
}
}
None
}
/// Verifica se uma URL está na lista de domínios bloqueados
fn is_domain_blocked(url: &str, excluded_domains: &[String]) -> bool {
if excluded_domains.is_empty() {
return false;
}
if let Some(domain) = extract_domain(url) {
let domain_lower = domain.to_lowercase();
for excluded in excluded_domains {
let excluded_lower = excluded.to_lowercase();
// Match exato ou subdomínio
if domain_lower == excluded_lower || domain_lower.ends_with(&format!(".{}", excluded_lower)) {
log::debug!("URL bloqueada por blacklist: {} (domínio: {})", url, excluded);
return true;
}
}
}
false
}
/// Verifica se uma URL é de anúncio/tracker (deve ser ignorada)
fn is_ad_or_tracker_url(url: &str) -> bool {
let ad_patterns = vec![
r"duckduckgo\.com/y\.js",
r"googleadservices\.com",
r"doubleclick\.net",
r"googlesyndication\.com",
r"aclick",
r"/y\.js",
r"advertising\.com",
r"adsystem\.com",
];
for pattern in ad_patterns {
if let Ok(re) = Regex::new(pattern) {
if re.is_match(url) {
log::debug!("URL de anúncio/tracker ignorada: {}", url);
return true;
}
}
}
false
}
/// Limpa URL removendo parâmetros de tracking e redirecionamento
fn clean_url(url: &str) -> Option<String> {
// Se for URL de anúncio, ignorar completamente
if is_ad_or_tracker_url(url) {
return None;
}
// Tentar extrair URL real de redirecionamentos do DuckDuckGo
if url.contains("duckduckgo.com") {
// Formato: /l/?kh=-1&uddg=<URL_ENCODED> ou /y.js?ad_provider=...
if url.contains("/y.js") || url.contains("aclick") {
return None; // É anúncio
}
if let Some(uddg_start) = url.find("uddg=") {
if let Some(encoded_url) = url.get(uddg_start + 5..) {
if let Some(ampersand) = encoded_url.find('&') {
if let Ok(decoded) = urlencoding::decode(&encoded_url[..ampersand]) {
return Some(decoded.to_string());
}
} else if let Ok(decoded) = urlencoding::decode(encoded_url) {
return Some(decoded.to_string());
}
}
}
}
// Se já for URL limpa, validar formato
if url.starts_with("http://") || url.starts_with("https://") {
Some(url.to_string())
} else {
None
}
}
/// Busca usando query "site:" para categorias específicas
async fn search_with_site_filter(query: &str, sites: &[String], limit: usize) -> Result<Vec<String>> {
if sites.is_empty() {
return Ok(Vec::new());
}
// Construir query: site:site1.com OR site:site2.com ... {query}
let site_filters: Vec<String> = sites.iter()
.map(|site| format!("site:{}", site))
.collect();
let site_query = format!("({}) {}", site_filters.join(" OR "), query);
search_duckduckgo(&site_query, limit).await
}
/// Busca inteligente híbrida: geral + curada por categorias
pub async fn smart_search(query: &str, config: &SearchConfig) -> Result<Vec<String>> {
let mut all_urls = Vec::new();
let mut seen_urls = std::collections::HashSet::new();
// 1. Busca geral no DuckDuckGo (ignorando anúncios)
log::info!("Executando busca geral para: {}", query);
let general_urls = search_duckduckgo(query, config.total_sources_limit).await?;
for url in general_urls {
if let Some(cleaned) = clean_url(&url) {
if !is_domain_blocked(&cleaned, &config.excluded_domains) {
if seen_urls.insert(cleaned.clone()) {
all_urls.push(cleaned);
}
}
}
}
// 2. Busca direta por categorias ativas (site: filters)
for category in &config.categories {
if !category.enabled || category.base_sites.is_empty() {
continue;
}
log::info!("Buscando em categoria '{}' ({} sites)", category.name, category.base_sites.len());
// Limitar sites por categoria para não exceder o limite total
let sites_to_search = category.base_sites.iter()
.take(config.total_sources_limit / config.categories.len().max(1))
.cloned()
.collect::<Vec<_>>();
match search_with_site_filter(query, &sites_to_search, config.total_sources_limit).await {
Ok(category_urls) => {
for url in category_urls {
if let Some(cleaned) = clean_url(&url) {
if !is_domain_blocked(&cleaned, &config.excluded_domains) {
if seen_urls.insert(cleaned.clone()) {
all_urls.push(cleaned);
}
}
}
}
}
Err(e) => {
log::warn!("Erro ao buscar categoria '{}': {}", category.name, e);
}
}
}
// 3. Adicionar sites customizados do usuário
if !config.user_custom_sites.is_empty() {
log::info!("Buscando em {} sites customizados", config.user_custom_sites.len());
match search_with_site_filter(query, &config.user_custom_sites, config.total_sources_limit).await {
Ok(custom_urls) => {
for url in custom_urls {
if let Some(cleaned) = clean_url(&url) {
if !is_domain_blocked(&cleaned, &config.excluded_domains) {
if seen_urls.insert(cleaned.clone()) {
all_urls.push(cleaned);
}
}
}
}
}
Err(e) => {
log::warn!("Erro ao buscar sites customizados: {}", e);
}
}
}
// Limitar ao total_sources_limit
all_urls.truncate(config.total_sources_limit);
log::info!("Total de {} URLs únicas coletadas", all_urls.len());
Ok(all_urls)
}
/// Busca e extrai conteúdo de múltiplas URLs em paralelo com Semaphore
pub async fn search_and_scrape(
query: &str,
limit: usize,
browser: Arc<Browser>,
excluded_domains: Vec<String>,
) -> Result<Vec<ScrapedContent>> {
// Configuração padrão (backward compatibility)
let config = SearchConfig {
max_concurrent_tabs: 5,
total_sources_limit: limit,
categories: Vec::new(),
user_custom_sites: Vec::new(),
excluded_domains,
};
search_and_scrape_with_config(query, &config, browser).await
}
/// Versão nova com SearchConfig completo
pub async fn search_and_scrape_with_config(
query: &str,
config: &SearchConfig,
browser: Arc<Browser>,
) -> Result<Vec<ScrapedContent>> {
// 1. Busca inteligente híbrida
let urls = smart_search(query, config).await?;
if urls.is_empty() {
log::warn!("Nenhuma URL encontrada para a query: {}", query);
return Ok(Vec::new());
}
// 2. Scraping paralelo com Semaphore (limita abas simultâneas)
let semaphore = Arc::new(Semaphore::new(config.max_concurrent_tabs));
let mut handles = Vec::new();
for url in urls {
let browser_clone = browser.clone();
let permit = semaphore.clone().acquire_owned().await.unwrap();
let handle = tokio::task::spawn_blocking(move || {
let result = fetch_and_convert_sync(&browser_clone, &url);
drop(permit); // Libera o semáforo após processar
result
});
handles.push(handle);
}
// 3. Coletar resultados (ignorar erros individuais, continuar com sucessos)
let mut results = Vec::new();
for handle in handles {
match handle.await {
Ok(Ok(content)) => {
// Filtrar conteúdo muito curto (< 200 caracteres)
let content_length = content.content.chars().count();
let markdown_length = content.markdown.chars().count();
if content_length < 200 && markdown_length < 200 {
log::debug!(
"Fonte descartada por conteúdo muito curto ({} chars): {}",
content_length.max(markdown_length),
content.url
);
} else {
results.push(content);
}
}
Ok(Err(e)) => {
let err_msg = format!("{}", e);
if err_msg.contains("Timeout") || err_msg.contains("ERR_HTTP") {
log::debug!("URL ignorada (timeout/erro HTTP): {}", err_msg);
} else {
log::warn!("Erro ao processar URL: {}", e);
}
}
Err(e) => {
log::warn!("Erro na task de scraping: {}", e);
}
}
}
if results.is_empty() {
log::warn!("Nenhuma fonte foi extraída com sucesso para a query: {}", query);
} else {
log::info!("Extraídas {} fontes com sucesso", results.len());
}
Ok(results)
}
/// Busca e extrai conteúdo de uma única URL
pub async fn scrape_url(
url: &str,
browser: Arc<Browser>,
) -> Result<ScrapedContent> {
let browser_clone = browser.clone();
let url_str = url.to_string();
tokio::task::spawn_blocking(move || {
fetch_and_convert_sync(&browser_clone, &url_str)
})
.await
.map_err(|e| anyhow::anyhow!("Erro na task: {}", e))?
}
/// Extrai conteúdo de uma URL e converte para Markdown (versão síncrona)
/// Retorna erro se timeout ou falha HTTP, mas não mata o processo
fn fetch_and_convert_sync(browser: &Browser, url: &str) -> Result<ScrapedContent> {
use std::time::Instant;
let start_time = Instant::now();
let max_duration = Duration::from_secs(10); // Timeout agressivo de 10s
// Criar nova aba com tratamento de erro
let tab = match browser.new_tab() {
Ok(t) => t,
Err(e) => {
log::warn!("Falha ao criar aba para {}: {}", url, e);
return Err(anyhow::anyhow!("Falha ao criar aba: {}", e));
}
};
// Timeout reduzido para navegação
tab.set_default_timeout(Duration::from_secs(8));
// Tentar navegar com tratamento de erro HTTP
match tab.navigate_to(url) {
Ok(_) => {},
Err(e) => {
let err_msg = format!("{}", e);
// Se for erro HTTP, apenas logar e retornar erro
if err_msg.contains("ERR_HTTP_RESPONSE_CODE_FAILURE") ||
err_msg.contains("net::ERR") {
log::warn!("Erro HTTP ao navegar para {}: {}", url, e);
return Err(anyhow::anyhow!("Erro HTTP: {}", err_msg));
}
return Err(anyhow::anyhow!("Falha ao navegar: {}", e));
}
}
// Aguardar navegação com verificação de timeout
match tab.wait_until_navigated() {
Ok(_) => {},
Err(e) => {
let err_msg = format!("{}", e);
if err_msg.contains("ERR_HTTP_RESPONSE_CODE_FAILURE") ||
err_msg.contains("net::ERR") {
log::warn!("Erro HTTP após navegação para {}: {}", url, e);
return Err(anyhow::anyhow!("Erro HTTP: {}", err_msg));
}
// Timeout ou outro erro
if start_time.elapsed() > max_duration {
log::warn!("Timeout ao aguardar navegação para {}", url);
return Err(anyhow::anyhow!("Timeout ao carregar página"));
}
return Err(anyhow::anyhow!("Falha ao aguardar navegação: {}", e));
}
}
// Verificar timeout antes de continuar
if start_time.elapsed() > max_duration {
log::warn!("Timeout antes de extrair conteúdo de {}", url);
return Err(anyhow::anyhow!("Timeout ao processar página"));
}
// Aguardar um pouco para JS/SPAs carregarem (máximo 1.5s)
let wait_time = Duration::from_millis(1500);
if start_time.elapsed() + wait_time < max_duration {
std::thread::sleep(wait_time);
} else {
let remaining = max_duration.saturating_sub(start_time.elapsed());
if !remaining.is_zero() {
std::thread::sleep(remaining);
}
}
// Verificar timeout final
if start_time.elapsed() > max_duration {
log::warn!("Timeout ao processar {}", url);
return Err(anyhow::anyhow!("Timeout ao processar página"));
}
// Extrair HTML renderizado
let content = match tab.get_content() {
Ok(c) => c,
Err(e) => {
log::warn!("Falha ao extrair conteúdo de {}: {}", url, e);
return Err(anyhow::anyhow!("Falha ao extrair HTML: {}", e));
}
};
// Limpeza com Readability (remove ads, menus, footers)
let mut reader = std::io::Cursor::new(content.as_bytes());
let url_obj = match Url::parse(url) {
Ok(u) => u,
Err(e) => {
log::warn!("URL inválida {}: {}", url, e);
return Err(anyhow::anyhow!("URL inválida: {}", e));
}
};
let product = match readability::extractor::extract(&mut reader, &url_obj) {
Ok(p) => p,
Err(e) => {
log::warn!("Falha ao extrair conteúdo legível de {}: {}", url, e);
return Err(anyhow::anyhow!("Falha ao processar conteúdo: {}", e));
}
};
// Conversão para Markdown
let markdown = html2text::from_read(product.content.as_bytes(), 80);
// Clonar title antes de mover para poder usar no format!
let title = product.title.clone();
let result = ScrapedContent {
title: product.title,
url: url.to_string(),
content: product.content,
markdown: format!(
"---\nTitle: {}\nSource: {}\n---\n\n{}",
title,
url,
markdown
),
};
Ok(result)
}
/// Cria uma instância do Browser (singleton para reutilização)
pub fn create_browser() -> Result<Browser> {
let options = LaunchOptions {
headless: true,
..Default::default()
};
Browser::new(options)
.map_err(|e| anyhow::anyhow!("Falha ao criar browser: {}", e))
}