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:
1 parent
032722e21b
commit
8834ef2b4f
55 files changed
+20030
-159
No files matched your search
Generated
+1543
-48
File diff suppressed because it is too large.
Load diff
+18
-1
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user