- Added speculative search functionality to enhance query processing by detecting when a web search may be needed based on user input patterns. - Integrated commands for managing embeddings, including downloading models, checking availability, calculating relevance scores, generating embeddings, and pruning context. - Optimized chat streaming by buffering tokens to reduce overhead and improve responsiveness. - Implemented lazy initialization for the headless browser to conserve memory until needed. - Enhanced web scraping capabilities with a static scraping method for faster content retrieval.
1873 lines
66 KiB
Rust
1873 lines
66 KiB
Rust
use anyhow::Result;
|
|
use headless_chrome::{Browser, LaunchOptions, Tab};
|
|
use reqwest::header::USER_AGENT;
|
|
use scraper::{Html, Selector};
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
use std::time::Duration;
|
|
use url::Url;
|
|
use rand::Rng;
|
|
use tokio::sync::Semaphore;
|
|
use regex::Regex;
|
|
use std::time::Instant;
|
|
|
|
/// Lazy-initialized global browser instance
|
|
/// Evita criar o browser no startup, economizando ~500MB de RAM até ser necessário
|
|
static LAZY_BROWSER: OnceLock<Mutex<Option<Arc<Browser>>>> = OnceLock::new();
|
|
|
|
/// Obtém ou cria a instância global do browser (lazy initialization)
|
|
pub fn get_or_create_browser() -> Result<Arc<Browser>> {
|
|
let mutex = LAZY_BROWSER.get_or_init(|| Mutex::new(None));
|
|
let mut guard = mutex.lock().map_err(|e| anyhow::anyhow!("Browser mutex poisoned: {}", e))?;
|
|
|
|
if guard.is_none() {
|
|
log::info!("[LazyBrowser] Initializing headless browser on first use...");
|
|
let browser = create_browser()?;
|
|
*guard = Some(Arc::new(browser));
|
|
log::info!("[LazyBrowser] Browser initialized successfully");
|
|
}
|
|
|
|
Ok(guard.as_ref().unwrap().clone())
|
|
}
|
|
|
|
/// Limpa a instância do browser (para liberar memória quando não em uso)
|
|
pub fn clear_browser() {
|
|
if let Some(mutex) = LAZY_BROWSER.get() {
|
|
if let Ok(mut guard) = mutex.lock() {
|
|
if guard.is_some() {
|
|
log::info!("[LazyBrowser] Clearing browser instance to free memory");
|
|
*guard = None;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
|
|
/// Metadados de resultado de busca (leve, sem abrir página)
|
|
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
|
|
pub struct SearchResultMetadata {
|
|
pub title: String,
|
|
pub url: String,
|
|
pub snippet: 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 {
|
|
100
|
|
}
|
|
|
|
/// Enum para identificar diferentes motores de busca
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SearchEngine {
|
|
Google,
|
|
Bing,
|
|
Yahoo,
|
|
DuckDuckGo,
|
|
Startpage,
|
|
}
|
|
|
|
impl SearchEngine {
|
|
/// Converte string para SearchEngine
|
|
pub fn from_str(s: &str) -> Option<Self> {
|
|
match s.to_lowercase().as_str() {
|
|
"google" => Some(SearchEngine::Google),
|
|
"bing" => Some(SearchEngine::Bing),
|
|
"yahoo" => Some(SearchEngine::Yahoo),
|
|
"duckduckgo" | "duck_duck_go" => Some(SearchEngine::DuckDuckGo),
|
|
"startpage" => Some(SearchEngine::Startpage),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Retorna nome do motor como string
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
SearchEngine::Google => "Google",
|
|
SearchEngine::Bing => "Bing",
|
|
SearchEngine::Yahoo => "Yahoo",
|
|
SearchEngine::DuckDuckGo => "DuckDuckGo",
|
|
SearchEngine::Startpage => "Startpage",
|
|
}
|
|
}
|
|
|
|
/// Retorna URL base de busca
|
|
fn base_url(&self) -> &'static str {
|
|
match self {
|
|
SearchEngine::Google => "https://www.google.com/search",
|
|
SearchEngine::Bing => "https://www.bing.com/search",
|
|
SearchEngine::Yahoo => "https://search.yahoo.com/search",
|
|
SearchEngine::DuckDuckGo => "https://html.duckduckgo.com/html",
|
|
SearchEngine::Startpage => "https://www.startpage.com/sp/search",
|
|
}
|
|
}
|
|
|
|
/// Retorna selectors CSS específicos para cada motor
|
|
fn selectors(&self) -> SearchSelectors {
|
|
match self {
|
|
SearchEngine::Google => SearchSelectors {
|
|
container: vec![
|
|
"div.g",
|
|
"div[data-ved]",
|
|
".tF2Cxc",
|
|
],
|
|
title: vec![
|
|
"h3",
|
|
".LC20lb",
|
|
".DKV0Md",
|
|
],
|
|
url: vec![
|
|
"a[href]",
|
|
"cite",
|
|
],
|
|
snippet: vec![
|
|
".VwiC3b",
|
|
".s",
|
|
".st",
|
|
],
|
|
},
|
|
SearchEngine::Bing => SearchSelectors {
|
|
container: vec![
|
|
".b_algo",
|
|
"li.b_algo",
|
|
],
|
|
title: vec![
|
|
"h2 a",
|
|
".b_title a",
|
|
],
|
|
url: vec![
|
|
"h2 a[href]",
|
|
".b_title a[href]",
|
|
],
|
|
snippet: vec![
|
|
".b_caption p",
|
|
".b_caption",
|
|
],
|
|
},
|
|
SearchEngine::Yahoo => SearchSelectors {
|
|
container: vec![
|
|
".dd.algo",
|
|
".Sr",
|
|
],
|
|
title: vec![
|
|
"h3 a",
|
|
".ac-algo h3 a",
|
|
],
|
|
url: vec![
|
|
"h3 a[href]",
|
|
".ac-algo h3 a[href]",
|
|
],
|
|
snippet: vec![
|
|
".ac-algo .ac-text",
|
|
".compText",
|
|
],
|
|
},
|
|
SearchEngine::DuckDuckGo => SearchSelectors {
|
|
container: vec![
|
|
".result",
|
|
".web-result",
|
|
".result__body",
|
|
],
|
|
title: vec![
|
|
".result__a",
|
|
".web-result__link",
|
|
"a.result__a",
|
|
],
|
|
url: vec![
|
|
".result__a[href]",
|
|
".web-result__link[href]",
|
|
],
|
|
snippet: vec![
|
|
".result__snippet",
|
|
".result__snippet.js-result-snippet",
|
|
".web-result__snippet",
|
|
],
|
|
},
|
|
SearchEngine::Startpage => SearchSelectors {
|
|
container: vec![
|
|
".w-gl__result",
|
|
".result",
|
|
],
|
|
title: vec![
|
|
".w-gl__result-title a",
|
|
"h3 a",
|
|
],
|
|
url: vec![
|
|
".w-gl__result-title a[href]",
|
|
"h3 a[href]",
|
|
],
|
|
snippet: vec![
|
|
".w-gl__result-snippet",
|
|
".snippet",
|
|
],
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Normaliza query para o motor específico
|
|
fn normalize_query(&self, query: &str) -> String {
|
|
// Todos os motores usam encoding padrão, mas alguns podem ter requisitos específicos
|
|
query.trim().to_string()
|
|
}
|
|
}
|
|
|
|
/// Estrutura para selectors CSS de cada motor
|
|
struct SearchSelectors {
|
|
container: Vec<&'static str>,
|
|
title: Vec<&'static str>,
|
|
url: Vec<&'static str>,
|
|
snippet: Vec<&'static str>,
|
|
}
|
|
|
|
/// Log de tentativa de busca em um motor
|
|
struct SearchAttemptLog {
|
|
engine: SearchEngine,
|
|
query: String,
|
|
success: bool,
|
|
results_count: usize,
|
|
duration_ms: u64,
|
|
error: Option<String>,
|
|
}
|
|
|
|
/// 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 user_agent = get_random_user_agent();
|
|
let mut links = Vec::new();
|
|
let mut offset = 0usize;
|
|
let max_pages = 5usize;
|
|
let selectors = vec![
|
|
".result__a",
|
|
".web-result__link",
|
|
"a.result__a",
|
|
];
|
|
for _ in 0..max_pages {
|
|
if links.len() >= limit { break; }
|
|
let url = format!(
|
|
"https://html.duckduckgo.com/html/?q={}&s={}",
|
|
urlencoding::encode(query),
|
|
offset
|
|
);
|
|
let res = client
|
|
.get(&url)
|
|
.header(USER_AGENT, user_agent)
|
|
.send()
|
|
.await?
|
|
.text()
|
|
.await?;
|
|
{
|
|
let document = Html::parse_document(&res);
|
|
for selector_str in &selectors {
|
|
if let Ok(selector) = Selector::parse(selector_str) {
|
|
for element in document.select(&selector) {
|
|
if let Some(href) = element.value().attr("href") {
|
|
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.len() >= limit { break; }
|
|
}
|
|
}
|
|
}
|
|
offset += 50;
|
|
if res.is_empty() { break; }
|
|
}
|
|
links.truncate(limit);
|
|
Ok(links)
|
|
}
|
|
|
|
/// Busca no Google retornando apenas metadados (título, URL, snippet)
|
|
pub async fn search_google_metadata(query: &str, limit: usize) -> Result<Vec<SearchResultMetadata>> {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(10))
|
|
.build()?;
|
|
|
|
let url = format!("{}?q={}&num={}",
|
|
SearchEngine::Google.base_url(),
|
|
urlencoding::encode(query),
|
|
limit.min(100)
|
|
);
|
|
|
|
let user_agent = get_random_user_agent();
|
|
let start_time = Instant::now();
|
|
|
|
log::info!("[SearchEngine:Google] Query: '{}', Attempting...", query);
|
|
|
|
let res = match client
|
|
.get(&url)
|
|
.header(USER_AGENT, user_agent)
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(r) => r.text().await?,
|
|
Err(e) => {
|
|
let duration = start_time.elapsed().as_millis() as u64;
|
|
log::warn!("[SearchEngine:Google] Failed: {} ({}ms)", e, duration);
|
|
return Err(anyhow::anyhow!("Google search failed: {}", e));
|
|
}
|
|
};
|
|
|
|
let mut results: Vec<SearchResultMetadata> = Vec::new();
|
|
let selectors = SearchEngine::Google.selectors();
|
|
let document = Html::parse_document(&res);
|
|
|
|
for cont_sel in &selectors.container {
|
|
if results.len() >= limit { break; }
|
|
if let Ok(container) = Selector::parse(cont_sel) {
|
|
for node in document.select(&container) {
|
|
if results.len() >= limit { break; }
|
|
|
|
let mut found_url: Option<String> = None;
|
|
let mut found_title: Option<String> = None;
|
|
|
|
// Buscar título
|
|
for tsel in &selectors.title {
|
|
if let Ok(ts) = Selector::parse(tsel) {
|
|
if let Some(a) = node.select(&ts).next() {
|
|
// Extrair URL
|
|
if let Some(href) = a.value().attr("href") {
|
|
let cleaned = clean_url(href);
|
|
if cleaned.is_some() {
|
|
found_url = cleaned;
|
|
}
|
|
}
|
|
// Extrair título
|
|
let text = a.text().collect::<Vec<_>>().join(" ").trim().to_string();
|
|
if !text.is_empty() { found_title = Some(text); }
|
|
}
|
|
if found_url.is_some() && found_title.is_some() { break; }
|
|
}
|
|
}
|
|
|
|
if found_url.is_none() { continue; }
|
|
|
|
// Buscar snippet
|
|
let mut snippet_text = String::new();
|
|
for ssel in &selectors.snippet {
|
|
if let Ok(ss) = Selector::parse(ssel) {
|
|
if let Some(s) = node.select(&ss).next() {
|
|
let t = s.text().collect::<Vec<_>>().join(" ");
|
|
let norm = t.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
if !norm.is_empty() { snippet_text = norm; break; }
|
|
}
|
|
}
|
|
}
|
|
|
|
let url_final = found_url.unwrap();
|
|
if is_ad_or_tracker_url(&url_final) || url_final.is_empty() { continue; }
|
|
|
|
results.push(SearchResultMetadata {
|
|
title: found_title.unwrap_or_else(|| url_final.clone()),
|
|
url: url_final,
|
|
snippet: snippet_text,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
let duration = start_time.elapsed().as_millis() as u64;
|
|
if results.is_empty() {
|
|
log::warn!("[SearchEngine:Google] No results found ({}ms)", duration);
|
|
} else {
|
|
log::info!("[SearchEngine:Google] Found {} results ({}ms)", results.len(), duration);
|
|
}
|
|
|
|
Ok(results)
|
|
}
|
|
|
|
/// Busca no Bing retornando apenas metadados (título, URL, snippet)
|
|
pub async fn search_bing_metadata(query: &str, limit: usize) -> Result<Vec<SearchResultMetadata>> {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(10))
|
|
.build()?;
|
|
|
|
let url = format!("{}?q={}&count={}",
|
|
SearchEngine::Bing.base_url(),
|
|
urlencoding::encode(query),
|
|
limit.min(50)
|
|
);
|
|
|
|
let user_agent = get_random_user_agent();
|
|
let start_time = Instant::now();
|
|
|
|
log::info!("[SearchEngine:Bing] Query: '{}', Attempting...", query);
|
|
|
|
let res = match client
|
|
.get(&url)
|
|
.header(USER_AGENT, user_agent)
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(r) => r.text().await?,
|
|
Err(e) => {
|
|
let duration = start_time.elapsed().as_millis() as u64;
|
|
log::warn!("[SearchEngine:Bing] Failed: {} ({}ms)", e, duration);
|
|
return Err(anyhow::anyhow!("Bing search failed: {}", e));
|
|
}
|
|
};
|
|
|
|
let mut results: Vec<SearchResultMetadata> = Vec::new();
|
|
let selectors = SearchEngine::Bing.selectors();
|
|
let document = Html::parse_document(&res);
|
|
|
|
for cont_sel in &selectors.container {
|
|
if results.len() >= limit { break; }
|
|
if let Ok(container) = Selector::parse(cont_sel) {
|
|
for node in document.select(&container) {
|
|
if results.len() >= limit { break; }
|
|
|
|
let mut found_url: Option<String> = None;
|
|
let mut found_title: Option<String> = None;
|
|
|
|
for tsel in &selectors.title {
|
|
if let Ok(ts) = Selector::parse(tsel) {
|
|
if let Some(a) = node.select(&ts).next() {
|
|
if let Some(href) = a.value().attr("href") {
|
|
let cleaned = clean_url(href);
|
|
if cleaned.is_some() {
|
|
found_url = cleaned;
|
|
}
|
|
}
|
|
let text = a.text().collect::<Vec<_>>().join(" ").trim().to_string();
|
|
if !text.is_empty() { found_title = Some(text); }
|
|
}
|
|
if found_url.is_some() && found_title.is_some() { break; }
|
|
}
|
|
}
|
|
|
|
if found_url.is_none() { continue; }
|
|
|
|
let mut snippet_text = String::new();
|
|
for ssel in &selectors.snippet {
|
|
if let Ok(ss) = Selector::parse(ssel) {
|
|
if let Some(s) = node.select(&ss).next() {
|
|
let t = s.text().collect::<Vec<_>>().join(" ");
|
|
let norm = t.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
if !norm.is_empty() { snippet_text = norm; break; }
|
|
}
|
|
}
|
|
}
|
|
|
|
let url_final = found_url.unwrap();
|
|
if is_ad_or_tracker_url(&url_final) || url_final.is_empty() { continue; }
|
|
|
|
results.push(SearchResultMetadata {
|
|
title: found_title.unwrap_or_else(|| url_final.clone()),
|
|
url: url_final,
|
|
snippet: snippet_text,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
let duration = start_time.elapsed().as_millis() as u64;
|
|
if results.is_empty() {
|
|
log::warn!("[SearchEngine:Bing] No results found ({}ms)", duration);
|
|
} else {
|
|
log::info!("[SearchEngine:Bing] Found {} results ({}ms)", results.len(), duration);
|
|
}
|
|
|
|
Ok(results)
|
|
}
|
|
|
|
/// Busca no Yahoo retornando apenas metadados (título, URL, snippet)
|
|
pub async fn search_yahoo_metadata(query: &str, limit: usize) -> Result<Vec<SearchResultMetadata>> {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(10))
|
|
.build()?;
|
|
|
|
let url = format!("{}?p={}&n={}",
|
|
SearchEngine::Yahoo.base_url(),
|
|
urlencoding::encode(query),
|
|
limit.min(40)
|
|
);
|
|
|
|
let user_agent = get_random_user_agent();
|
|
let start_time = Instant::now();
|
|
|
|
log::info!("[SearchEngine:Yahoo] Query: '{}', Attempting...", query);
|
|
|
|
let res = match client
|
|
.get(&url)
|
|
.header(USER_AGENT, user_agent)
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(r) => r.text().await?,
|
|
Err(e) => {
|
|
let duration = start_time.elapsed().as_millis() as u64;
|
|
log::warn!("[SearchEngine:Yahoo] Failed: {} ({}ms)", e, duration);
|
|
return Err(anyhow::anyhow!("Yahoo search failed: {}", e));
|
|
}
|
|
};
|
|
|
|
let mut results: Vec<SearchResultMetadata> = Vec::new();
|
|
let selectors = SearchEngine::Yahoo.selectors();
|
|
let document = Html::parse_document(&res);
|
|
|
|
for cont_sel in &selectors.container {
|
|
if results.len() >= limit { break; }
|
|
if let Ok(container) = Selector::parse(cont_sel) {
|
|
for node in document.select(&container) {
|
|
if results.len() >= limit { break; }
|
|
|
|
let mut found_url: Option<String> = None;
|
|
let mut found_title: Option<String> = None;
|
|
|
|
for tsel in &selectors.title {
|
|
if let Ok(ts) = Selector::parse(tsel) {
|
|
if let Some(a) = node.select(&ts).next() {
|
|
if let Some(href) = a.value().attr("href") {
|
|
let cleaned = clean_url(href);
|
|
if cleaned.is_some() {
|
|
found_url = cleaned;
|
|
}
|
|
}
|
|
let text = a.text().collect::<Vec<_>>().join(" ").trim().to_string();
|
|
if !text.is_empty() { found_title = Some(text); }
|
|
}
|
|
if found_url.is_some() && found_title.is_some() { break; }
|
|
}
|
|
}
|
|
|
|
if found_url.is_none() { continue; }
|
|
|
|
let mut snippet_text = String::new();
|
|
for ssel in &selectors.snippet {
|
|
if let Ok(ss) = Selector::parse(ssel) {
|
|
if let Some(s) = node.select(&ss).next() {
|
|
let t = s.text().collect::<Vec<_>>().join(" ");
|
|
let norm = t.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
if !norm.is_empty() { snippet_text = norm; break; }
|
|
}
|
|
}
|
|
}
|
|
|
|
let url_final = found_url.unwrap();
|
|
if is_ad_or_tracker_url(&url_final) || url_final.is_empty() { continue; }
|
|
|
|
results.push(SearchResultMetadata {
|
|
title: found_title.unwrap_or_else(|| url_final.clone()),
|
|
url: url_final,
|
|
snippet: snippet_text,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
let duration = start_time.elapsed().as_millis() as u64;
|
|
if results.is_empty() {
|
|
log::warn!("[SearchEngine:Yahoo] No results found ({}ms)", duration);
|
|
} else {
|
|
log::info!("[SearchEngine:Yahoo] Found {} results ({}ms)", results.len(), duration);
|
|
}
|
|
|
|
Ok(results)
|
|
}
|
|
|
|
/// Busca no Startpage retornando apenas metadados (título, URL, snippet)
|
|
pub async fn search_startpage_metadata(query: &str, limit: usize) -> Result<Vec<SearchResultMetadata>> {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(10))
|
|
.build()?;
|
|
|
|
let url = format!("{}?query={}&page=1",
|
|
SearchEngine::Startpage.base_url(),
|
|
urlencoding::encode(query)
|
|
);
|
|
|
|
let user_agent = get_random_user_agent();
|
|
let start_time = Instant::now();
|
|
|
|
log::info!("[SearchEngine:Startpage] Query: '{}', Attempting...", query);
|
|
|
|
let res = match client
|
|
.get(&url)
|
|
.header(USER_AGENT, user_agent)
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(r) => r.text().await?,
|
|
Err(e) => {
|
|
let duration = start_time.elapsed().as_millis() as u64;
|
|
log::warn!("[SearchEngine:Startpage] Failed: {} ({}ms)", e, duration);
|
|
return Err(anyhow::anyhow!("Startpage search failed: {}", e));
|
|
}
|
|
};
|
|
|
|
let mut results: Vec<SearchResultMetadata> = Vec::new();
|
|
let selectors = SearchEngine::Startpage.selectors();
|
|
let document = Html::parse_document(&res);
|
|
|
|
for cont_sel in &selectors.container {
|
|
if results.len() >= limit { break; }
|
|
if let Ok(container) = Selector::parse(cont_sel) {
|
|
for node in document.select(&container) {
|
|
if results.len() >= limit { break; }
|
|
|
|
let mut found_url: Option<String> = None;
|
|
let mut found_title: Option<String> = None;
|
|
|
|
for tsel in &selectors.title {
|
|
if let Ok(ts) = Selector::parse(tsel) {
|
|
if let Some(a) = node.select(&ts).next() {
|
|
if let Some(href) = a.value().attr("href") {
|
|
let cleaned = clean_url(href);
|
|
if cleaned.is_some() {
|
|
found_url = cleaned;
|
|
}
|
|
}
|
|
let text = a.text().collect::<Vec<_>>().join(" ").trim().to_string();
|
|
if !text.is_empty() { found_title = Some(text); }
|
|
}
|
|
if found_url.is_some() && found_title.is_some() { break; }
|
|
}
|
|
}
|
|
|
|
if found_url.is_none() { continue; }
|
|
|
|
let mut snippet_text = String::new();
|
|
for ssel in &selectors.snippet {
|
|
if let Ok(ss) = Selector::parse(ssel) {
|
|
if let Some(s) = node.select(&ss).next() {
|
|
let t = s.text().collect::<Vec<_>>().join(" ");
|
|
let norm = t.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
if !norm.is_empty() { snippet_text = norm; break; }
|
|
}
|
|
}
|
|
}
|
|
|
|
let url_final = found_url.unwrap();
|
|
if is_ad_or_tracker_url(&url_final) || url_final.is_empty() { continue; }
|
|
|
|
results.push(SearchResultMetadata {
|
|
title: found_title.unwrap_or_else(|| url_final.clone()),
|
|
url: url_final,
|
|
snippet: snippet_text,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
let duration = start_time.elapsed().as_millis() as u64;
|
|
if results.is_empty() {
|
|
log::warn!("[SearchEngine:Startpage] No results found ({}ms)", duration);
|
|
} else {
|
|
log::info!("[SearchEngine:Startpage] Found {} results ({}ms)", results.len(), duration);
|
|
}
|
|
|
|
Ok(results)
|
|
}
|
|
|
|
/// Busca no DuckDuckGo retornando apenas metadados (título, URL, snippet)
|
|
pub async fn search_duckduckgo_metadata(query: &str, limit: usize) -> Result<Vec<SearchResultMetadata>> {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(10))
|
|
.build()?;
|
|
|
|
let url = format!("https://html.duckduckgo.com/html/?q={}",
|
|
urlencoding::encode(query));
|
|
|
|
let user_agent = get_random_user_agent();
|
|
let res = client
|
|
.get(&url)
|
|
.header(USER_AGENT, user_agent)
|
|
.send()
|
|
.await?
|
|
.text()
|
|
.await?;
|
|
|
|
let mut results: Vec<SearchResultMetadata> = Vec::new();
|
|
|
|
{
|
|
let document = Html::parse_document(&res);
|
|
|
|
// Estruturas comuns no HTML do DuckDuckGo
|
|
let container_selectors = vec![
|
|
".result",
|
|
".web-result",
|
|
".result__body",
|
|
];
|
|
let title_selectors = vec![
|
|
".result__a",
|
|
".web-result__link",
|
|
"a.result__a",
|
|
];
|
|
let snippet_selectors = vec![
|
|
".result__snippet",
|
|
".result__snippet.js-result-snippet",
|
|
".web-result__snippet",
|
|
];
|
|
|
|
for cont_sel in &container_selectors {
|
|
if results.len() >= limit { break; }
|
|
if let Ok(container) = Selector::parse(cont_sel) {
|
|
for node in document.select(&container) {
|
|
if results.len() >= limit { break; }
|
|
// Title + URL
|
|
let mut found_url: Option<String> = None;
|
|
let mut found_title: Option<String> = None;
|
|
for tsel in &title_selectors {
|
|
if let Ok(ts) = Selector::parse(tsel) {
|
|
if let Some(a) = node.select(&ts).next() {
|
|
if let Some(href) = a.value().attr("href") {
|
|
if let Some(real_url) = extract_real_url(href) {
|
|
found_url = clean_url(&real_url);
|
|
}
|
|
}
|
|
let text = a.text().collect::<Vec<_>>().join(" ").trim().to_string();
|
|
if !text.is_empty() { found_title = Some(text); }
|
|
}
|
|
}
|
|
if found_url.is_some() && found_title.is_some() { break; }
|
|
}
|
|
|
|
if found_url.is_none() { continue; }
|
|
|
|
// Snippet
|
|
let mut snippet_text: String = String::new();
|
|
for ssel in &snippet_selectors {
|
|
if let Ok(ss) = Selector::parse(ssel) {
|
|
if let Some(s) = node.select(&ss).next() {
|
|
let t = s.text().collect::<Vec<_>>().join(" ");
|
|
let norm = t.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
if !norm.is_empty() { snippet_text = norm; break; }
|
|
}
|
|
}
|
|
}
|
|
|
|
let url_final = found_url.unwrap();
|
|
if is_ad_or_tracker_url(&url_final) || url_final.is_empty() { continue; }
|
|
|
|
results.push(SearchResultMetadata {
|
|
title: found_title.unwrap_or_else(|| url_final.clone()),
|
|
url: url_final,
|
|
snippet: snippet_text,
|
|
});
|
|
|
|
if results.len() >= limit { break; }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Se ainda vazio, tentar fallback simples: extrair todos os links conhecidos
|
|
if results.is_empty() {
|
|
let links = search_duckduckgo(query, limit).await?;
|
|
for l in links {
|
|
let url_clean = clean_url(&l).unwrap_or(l);
|
|
results.push(SearchResultMetadata {
|
|
title: url_clean.clone(),
|
|
url: url_clean,
|
|
snippet: String::new(),
|
|
});
|
|
if results.len() >= limit { break; }
|
|
}
|
|
}
|
|
|
|
Ok(results)
|
|
}
|
|
|
|
/// Calcula score de relevância baseado em matches de palavras-chave
|
|
fn calculate_relevance_score(result: &SearchResultMetadata, query: &str) -> f32 {
|
|
let query_lower = query.to_lowercase();
|
|
let query_words: Vec<&str> = query_lower.split_whitespace()
|
|
.filter(|w| w.len() > 2)
|
|
.collect();
|
|
|
|
if query_words.is_empty() {
|
|
return 0.5; // Score neutro se não há palavras-chave
|
|
}
|
|
|
|
let title_lower = result.title.to_lowercase();
|
|
let snippet_lower = result.snippet.to_lowercase();
|
|
let combined = format!("{} {}", title_lower, snippet_lower);
|
|
|
|
let mut matches = 0;
|
|
for word in &query_words {
|
|
if combined.contains(word) {
|
|
matches += 1;
|
|
}
|
|
}
|
|
|
|
let base_score = matches as f32 / query_words.len() as f32;
|
|
|
|
// Bônus se palavra está no título
|
|
let title_matches = query_words.iter()
|
|
.filter(|w| title_lower.contains(*w))
|
|
.count();
|
|
let title_bonus = (title_matches as f32 / query_words.len() as f32) * 0.3;
|
|
|
|
// Bônus se snippet não está vazio
|
|
let snippet_bonus = if !result.snippet.is_empty() { 0.1 } else { 0.0 };
|
|
|
|
(base_score + title_bonus + snippet_bonus).min(1.0)
|
|
}
|
|
|
|
/// Busca multi-engine com fallback automático
|
|
pub async fn search_multi_engine_metadata(
|
|
query: &str,
|
|
limit: usize,
|
|
engine_order: &[SearchEngine],
|
|
min_results: usize,
|
|
) -> Result<Vec<SearchResultMetadata>> {
|
|
let mut all_results: Vec<SearchResultMetadata> = Vec::new();
|
|
let mut seen_urls = std::collections::HashSet::new();
|
|
let mut attempt_logs: Vec<SearchAttemptLog> = Vec::new();
|
|
|
|
log::info!("[MultiEngine] Starting search for: '{}'", query);
|
|
log::info!("[MultiEngine] Engine order: {:?}", engine_order.iter().map(|e| e.as_str()).collect::<Vec<_>>());
|
|
log::info!("[MultiEngine] Min results required: {}", min_results);
|
|
|
|
for engine in engine_order {
|
|
let start_time = Instant::now();
|
|
let mut attempt_log = SearchAttemptLog {
|
|
engine: *engine,
|
|
query: query.to_string(),
|
|
success: false,
|
|
results_count: 0,
|
|
duration_ms: 0,
|
|
error: None,
|
|
};
|
|
|
|
let result = match *engine {
|
|
SearchEngine::Google => search_google_metadata(query, limit).await,
|
|
SearchEngine::Bing => search_bing_metadata(query, limit).await,
|
|
SearchEngine::Yahoo => search_yahoo_metadata(query, limit).await,
|
|
SearchEngine::DuckDuckGo => search_duckduckgo_metadata(query, limit).await,
|
|
SearchEngine::Startpage => search_startpage_metadata(query, limit).await,
|
|
};
|
|
|
|
attempt_log.duration_ms = start_time.elapsed().as_millis() as u64;
|
|
|
|
match result {
|
|
Ok(mut engine_results) => {
|
|
// Filtrar duplicatas
|
|
engine_results.retain(|r| {
|
|
if seen_urls.contains(&r.url) {
|
|
false
|
|
} else {
|
|
seen_urls.insert(r.url.clone());
|
|
true
|
|
}
|
|
});
|
|
|
|
attempt_log.results_count = engine_results.len();
|
|
attempt_log.success = true;
|
|
|
|
if !engine_results.is_empty() {
|
|
log::info!("[MultiEngine:{}] Found {} unique results ({}ms)",
|
|
engine.as_str(), engine_results.len(), attempt_log.duration_ms);
|
|
all_results.extend(engine_results);
|
|
|
|
// Se atingiu mínimo necessário, pode parar
|
|
if all_results.len() >= min_results {
|
|
log::info!("[MultiEngine] Minimum results ({}) reached, stopping early", min_results);
|
|
break;
|
|
}
|
|
} else {
|
|
log::warn!("[MultiEngine:{}] No results found ({}ms), trying next engine...",
|
|
engine.as_str(), attempt_log.duration_ms);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
let error_msg = format!("{}", e);
|
|
attempt_log.error = Some(error_msg.clone());
|
|
log::warn!("[MultiEngine:{}] Failed: {} ({}ms), trying next engine...",
|
|
engine.as_str(), error_msg, attempt_log.duration_ms);
|
|
}
|
|
}
|
|
|
|
attempt_logs.push(attempt_log);
|
|
}
|
|
|
|
// Ranquear resultados por relevância
|
|
let mut scored_results: Vec<(SearchResultMetadata, f32)> = all_results
|
|
.into_iter()
|
|
.map(|r| {
|
|
let score = calculate_relevance_score(&r, query);
|
|
(r, score)
|
|
})
|
|
.collect();
|
|
|
|
// Ordenar por score (maior primeiro)
|
|
scored_results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
|
|
|
// Retornar top limit resultados
|
|
let final_results: Vec<SearchResultMetadata> = scored_results
|
|
.into_iter()
|
|
.take(limit)
|
|
.map(|(r, _)| r)
|
|
.collect();
|
|
|
|
// Log resumo
|
|
log::info!("[MultiEngine] Final results: {} (from {} engines)",
|
|
final_results.len(), attempt_logs.len());
|
|
for log_entry in &attempt_logs {
|
|
if log_entry.success {
|
|
log::info!(" ✓ {}: {} results ({}ms)",
|
|
log_entry.engine.as_str(), log_entry.results_count, log_entry.duration_ms);
|
|
} else {
|
|
log::warn!(" ✗ {}: Failed - {} ({}ms)",
|
|
log_entry.engine.as_str(),
|
|
log_entry.error.as_ref().unwrap_or(&"Unknown error".to_string()),
|
|
log_entry.duration_ms);
|
|
}
|
|
}
|
|
|
|
Ok(final_results)
|
|
}
|
|
|
|
/// Expande query semanticamente (adiciona sinônimos, remove stopwords)
|
|
pub fn expand_query_semantic(query: &str, language: &str) -> Vec<String> {
|
|
let mut variants = Vec::new();
|
|
|
|
// Query original sempre incluída
|
|
variants.push(query.trim().to_string());
|
|
|
|
// Stopwords por idioma
|
|
let stopwords: Vec<&str> = match language {
|
|
"pt-BR" | "pt" => vec!["o", "a", "os", "as", "de", "do", "da", "dos", "das", "em", "no", "na", "nos", "nas", "para", "por", "com", "sem", "que", "qual", "quais"],
|
|
"en" => vec!["the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by"],
|
|
"es" => vec!["el", "la", "los", "las", "de", "del", "en", "un", "una", "para", "por", "con", "sin"],
|
|
_ => vec![],
|
|
};
|
|
|
|
// Remover stopwords
|
|
let words: Vec<&str> = query.split_whitespace()
|
|
.filter(|w| !stopwords.contains(&w.to_lowercase().as_str()))
|
|
.collect();
|
|
|
|
if words.len() > 1 {
|
|
let without_stopwords = words.join(" ");
|
|
if without_stopwords != query.trim() {
|
|
variants.push(without_stopwords);
|
|
}
|
|
}
|
|
|
|
// Sinônimos comuns (básico - pode ser expandido)
|
|
let synonyms: Vec<(&str, &str)> = match language {
|
|
"pt-BR" | "pt" => vec![
|
|
("pesquisa", "estudo investigação"),
|
|
("resultado", "achado descoberta"),
|
|
("acadêmico", "científico universitário"),
|
|
],
|
|
"en" => vec![
|
|
("research", "study investigation"),
|
|
("result", "finding discovery"),
|
|
("academic", "scientific scholarly"),
|
|
],
|
|
_ => vec![],
|
|
};
|
|
|
|
// Adicionar variantes com sinônimos
|
|
for (original, replacements) in synonyms {
|
|
if query.to_lowercase().contains(original) {
|
|
for replacement in replacements.split_whitespace() {
|
|
let variant = query.to_lowercase().replace(original, replacement);
|
|
if variant != query.to_lowercase() {
|
|
variants.push(variant);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remover duplicatas e retornar
|
|
variants.sort();
|
|
variants.dedup();
|
|
variants
|
|
}
|
|
|
|
/// 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.clone() {
|
|
let browser_clone = browser.clone();
|
|
let permit = semaphore.clone().acquire_owned().await.unwrap();
|
|
let url_clone = url.clone();
|
|
let handle = tokio::task::spawn_blocking(move || {
|
|
let res = fetch_and_convert_sync(&browser_clone, &url_clone);
|
|
drop(permit);
|
|
(url_clone, res)
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
// 3. Coletar resultados (ignorar erros individuais, continuar com sucessos)
|
|
let mut results = Vec::new();
|
|
let mut failed_urls = Vec::new();
|
|
let mut connection_closed = false;
|
|
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((url, 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);
|
|
failed_urls.push(url);
|
|
} else {
|
|
log::warn!("Erro ao processar URL: {}", e);
|
|
if err_msg.contains("underlying connection is closed") {
|
|
connection_closed = true;
|
|
failed_urls.push(url);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::warn!("Erro na task de scraping: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
if connection_closed && !failed_urls.is_empty() {
|
|
let retry_concurrency = std::cmp::min(3, config.max_concurrent_tabs.max(1));
|
|
let semaphore = Arc::new(Semaphore::new(retry_concurrency));
|
|
let browser_new = get_or_create_browser()?;
|
|
let mut retry_handles = Vec::new();
|
|
for url in failed_urls.clone() {
|
|
let browser_clone = browser_new.clone();
|
|
let permit = semaphore.clone().acquire_owned().await.unwrap();
|
|
let url_clone = url.clone();
|
|
let handle = tokio::task::spawn_blocking(move || {
|
|
let res = fetch_and_convert_sync(&browser_clone, &url_clone);
|
|
drop(permit);
|
|
(url_clone, res)
|
|
});
|
|
retry_handles.push(handle);
|
|
}
|
|
for h in retry_handles {
|
|
match h.await {
|
|
Ok((_, Ok(content))) => {
|
|
let content_length = content.content.chars().count();
|
|
let markdown_length = content.markdown.chars().count();
|
|
if content_length >= 200 || markdown_length >= 200 {
|
|
results.push(content);
|
|
}
|
|
}
|
|
Ok((url, Err(e))) => {
|
|
log::warn!("Falha após retry para URL {}: {}", url, e);
|
|
}
|
|
Err(e) => log::warn!("Erro na task de retry: {}", 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)
|
|
}
|
|
|
|
/// Scraping estático usando apenas reqwest (sem headless browser)
|
|
/// Muito mais rápido (~100ms vs ~3s) e consome menos RAM
|
|
/// Retorna None se o conteúdo for insuficiente (SPA/JavaScript-heavy)
|
|
pub async fn scrape_url_static(url: &str) -> Result<Option<ScrapedContent>> {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(8))
|
|
.redirect(reqwest::redirect::Policy::limited(5))
|
|
.build()?;
|
|
|
|
let user_agent = get_random_user_agent();
|
|
let start_time = Instant::now();
|
|
|
|
log::debug!("[StaticScrape] Fetching: {}", url);
|
|
|
|
let response = match client
|
|
.get(url)
|
|
.header(USER_AGENT, user_agent)
|
|
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
|
.header("Accept-Language", "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7")
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
log::debug!("[StaticScrape] Request failed for {}: {}", url, e);
|
|
return Ok(None);
|
|
}
|
|
};
|
|
|
|
if !response.status().is_success() {
|
|
log::debug!("[StaticScrape] HTTP {} for {}", response.status(), url);
|
|
return Ok(None);
|
|
}
|
|
|
|
let html = match response.text().await {
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
log::debug!("[StaticScrape] Failed to read body for {}: {}", url, e);
|
|
return Ok(None);
|
|
}
|
|
};
|
|
|
|
let duration = start_time.elapsed().as_millis();
|
|
|
|
// Usar extract_paragraph_fallback existente
|
|
let result = extract_paragraph_fallback(url, &html);
|
|
|
|
if let Some(ref content) = result {
|
|
log::info!("[StaticScrape] Success for {} ({} chars, {}ms)",
|
|
url, content.content.len(), duration);
|
|
} else {
|
|
log::debug!("[StaticScrape] Insufficient content for {} ({}ms)", url, duration);
|
|
}
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
/// Busca e extrai conteúdo de uma única URL (híbrido: tenta estático primeiro)
|
|
pub async fn scrape_url(
|
|
url: &str,
|
|
browser: Arc<Browser>,
|
|
) -> Result<ScrapedContent> {
|
|
// OTIMIZAÇÃO: Tentar scraping estático primeiro (muito mais rápido)
|
|
if let Ok(Some(content)) = scrape_url_static(url).await {
|
|
// Se conseguiu conteúdo suficiente (>500 chars), usar resultado estático
|
|
if content.content.len() > 500 {
|
|
log::info!("[ScrapeHybrid] Using static result for {} ({} chars)", url, content.content.len());
|
|
return Ok(content);
|
|
}
|
|
}
|
|
|
|
// Fallback: usar headless browser para SPAs/JS-heavy pages
|
|
log::info!("[ScrapeHybrid] Falling back to headless for {}", url);
|
|
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 múltiplas URLs já definidas (bulk)
|
|
pub async fn scrape_urls_bulk(
|
|
urls: Vec<String>,
|
|
browser: Arc<Browser>,
|
|
) -> Result<Vec<ScrapedContent>> {
|
|
if urls.is_empty() { return Ok(Vec::new()); }
|
|
let concurrency = 5usize;
|
|
let semaphore = Arc::new(Semaphore::new(concurrency));
|
|
let mut handles = Vec::new();
|
|
|
|
for url in urls {
|
|
let browser_clone = browser.clone();
|
|
let permit = semaphore.clone().acquire_owned().await.unwrap();
|
|
let url_clone = url.clone();
|
|
let handle = tokio::task::spawn_blocking(move || {
|
|
let res = fetch_and_convert_sync(&browser_clone, &url_clone);
|
|
drop(permit);
|
|
res
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
let mut results = Vec::new();
|
|
for h in handles {
|
|
match h.await {
|
|
Ok(Ok(content)) => {
|
|
let content_len = content.content.chars().count();
|
|
let md_len = content.markdown.chars().count();
|
|
if content_len < 200 && md_len < 200 {
|
|
log::debug!("Descartado por conteúdo curto: {}", content.url);
|
|
} else {
|
|
results.push(content);
|
|
}
|
|
}
|
|
Ok(Err(e)) => {
|
|
let msg = format!("{}", e);
|
|
if msg.contains("Timeout") || msg.contains("ERR_HTTP") {
|
|
log::debug!("Ignorado (timeout/HTTP): {}", msg);
|
|
} else {
|
|
log::warn!("Erro ao processar URL: {}", e);
|
|
}
|
|
}
|
|
Err(e) => log::warn!("Erro na task de scraping: {}", e),
|
|
}
|
|
}
|
|
|
|
Ok(results)
|
|
}
|
|
|
|
/// 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"));
|
|
}
|
|
|
|
// Injetar script para bloquear autoplay de mídia
|
|
// Isso garante que nenhum vídeo/áudio seja reproduzido durante o scraping
|
|
match disable_media_autoplay(&tab) {
|
|
Ok(_) => {
|
|
log::debug!("Autoplay de mídia bloqueado para: {}", url);
|
|
}
|
|
Err(e) => {
|
|
log::warn!("Aviso: Falha ao bloquear autoplay em {}: {}", url, e);
|
|
// Não falhar o scraping por causa disso, apenas logar
|
|
}
|
|
}
|
|
|
|
// Aguardar pequeno delay para garantir que script foi executado
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
|
|
// 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));
|
|
}
|
|
};
|
|
|
|
match readability::extractor::extract(&mut reader, &url_obj) {
|
|
Ok(product) => {
|
|
let markdown = html2text::from_read(product.content.as_bytes(), 80);
|
|
// Se o markdown for muito curto, significa que o readability pode ter falhado
|
|
if markdown.trim().chars().count() < 400 {
|
|
if let Some(fallback) = extract_paragraph_fallback(url, &content) {
|
|
log::info!("Fallback de parágrafos aplicado para {}", url);
|
|
return Ok(fallback);
|
|
}
|
|
}
|
|
|
|
let title = if product.title.is_empty() {
|
|
fallback_title(&content).unwrap_or_else(|| "Fonte externa sem título".to_string())
|
|
} else {
|
|
product.title.clone()
|
|
};
|
|
|
|
Ok(ScrapedContent {
|
|
title: title.clone(),
|
|
url: url.to_string(),
|
|
content: product.content,
|
|
markdown: format!(
|
|
"---\nTitle: {}\nSource: {}\n---\n\n{}",
|
|
title,
|
|
url,
|
|
markdown
|
|
),
|
|
})
|
|
}
|
|
Err(e) => {
|
|
log::warn!("Falha ao extrair conteúdo legível de {}: {}. Tentando fallback...", url, e);
|
|
if let Some(fallback) = extract_paragraph_fallback(url, &content) {
|
|
return Ok(fallback);
|
|
}
|
|
Err(anyhow::anyhow!("Falha ao processar conteúdo: {}", e))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Desabilita autoplay de mídia injetando JavaScript na página
|
|
/// Esta função pausa todos os elementos de vídeo/áudio e previne autoplay
|
|
fn disable_media_autoplay(tab: &Tab) -> Result<()> {
|
|
let script = r#"
|
|
(function() {
|
|
// Função para pausar todos os elementos de mídia
|
|
const pauseAllMedia = () => {
|
|
const mediaElements = document.querySelectorAll('video, audio');
|
|
let pausedCount = 0;
|
|
|
|
mediaElements.forEach(media => {
|
|
if (!media.paused) {
|
|
media.pause();
|
|
pausedCount++;
|
|
}
|
|
// Remover atributo autoplay
|
|
media.removeAttribute('autoplay');
|
|
media.autoplay = false;
|
|
// Silenciar mídia
|
|
media.muted = true;
|
|
media.volume = 0;
|
|
});
|
|
|
|
return pausedCount;
|
|
};
|
|
|
|
// Executar imediatamente
|
|
const initialPaused = pauseAllMedia();
|
|
|
|
// Observar mudanças no DOM para novos elementos de mídia
|
|
const observer = new MutationObserver(() => {
|
|
pauseAllMedia();
|
|
});
|
|
|
|
if (document.body) {
|
|
observer.observe(document.body, {
|
|
childList: true,
|
|
subtree: true
|
|
});
|
|
}
|
|
|
|
// Prevenir autoplay interceptando o método play()
|
|
const originalPlay = HTMLMediaElement.prototype.play;
|
|
HTMLMediaElement.prototype.play = function() {
|
|
// Bloquear play se página não estiver em foco ou estiver oculta
|
|
if (document.hidden || !document.hasFocus()) {
|
|
return Promise.reject(new Error('Autoplay blocked by scraper'));
|
|
}
|
|
// Pausar imediatamente após tentativa de play
|
|
const result = originalPlay.call(this);
|
|
if (result && typeof result.then === 'function') {
|
|
result.then(() => {
|
|
this.pause();
|
|
this.muted = true;
|
|
}).catch(() => {});
|
|
} else {
|
|
this.pause();
|
|
this.muted = true;
|
|
}
|
|
return Promise.reject(new Error('Autoplay blocked'));
|
|
};
|
|
|
|
// Pausar quando página perder foco
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.hidden) {
|
|
pauseAllMedia();
|
|
}
|
|
});
|
|
|
|
// Bloquear Web Audio API
|
|
if (window.AudioContext) {
|
|
const OriginalAudioContext = window.AudioContext;
|
|
window.AudioContext = function() {
|
|
console.warn('AudioContext blocked by scraper');
|
|
return null;
|
|
};
|
|
window.AudioContext.prototype = OriginalAudioContext.prototype;
|
|
}
|
|
|
|
if (window.webkitAudioContext) {
|
|
const OriginalWebkitAudioContext = window.webkitAudioContext;
|
|
window.webkitAudioContext = function() {
|
|
console.warn('webkitAudioContext blocked by scraper');
|
|
return null;
|
|
};
|
|
window.webkitAudioContext.prototype = OriginalWebkitAudioContext.prototype;
|
|
}
|
|
|
|
// Bloquear mídia em iframes também
|
|
const iframes = document.querySelectorAll('iframe');
|
|
iframes.forEach(iframe => {
|
|
try {
|
|
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
|
|
if (iframeDoc) {
|
|
const iframeMedia = iframeDoc.querySelectorAll('video, audio');
|
|
iframeMedia.forEach(media => {
|
|
media.pause();
|
|
media.muted = true;
|
|
media.volume = 0;
|
|
media.removeAttribute('autoplay');
|
|
});
|
|
}
|
|
} catch (e) {
|
|
// Ignorar erros de cross-origin
|
|
}
|
|
});
|
|
|
|
// Retornar contagem para logging
|
|
return initialPaused;
|
|
})();
|
|
"#;
|
|
|
|
match tab.evaluate(script, false) {
|
|
Ok(result) => {
|
|
// Tentar extrair contagem de elementos pausados do resultado
|
|
if let Some(count) = result.value {
|
|
log::info!("Script de bloqueio de mídia injetado: {} elementos pausados", count);
|
|
} else {
|
|
log::debug!("Script de bloqueio de mídia injetado com sucesso");
|
|
}
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
log::warn!("Erro ao injetar script de bloqueio de mídia: {}", e);
|
|
// Não falhar o scraping por causa disso, apenas logar
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Cria uma instância do Browser (singleton para reutilização)
|
|
pub fn create_browser() -> Result<Browser> {
|
|
use std::ffi::OsStr;
|
|
|
|
// Argumentos do Chrome para bloquear autoplay de mídia
|
|
// Nota: O bloqueio principal será feito via JavaScript injection, mas esses args ajudam
|
|
let chrome_args: Vec<&OsStr> = vec![
|
|
OsStr::new("--autoplay-policy=document-user-activation-required"), // Exige interação do usuário para autoplay
|
|
OsStr::new("--disable-background-media-playback"), // Desabilita reprodução de mídia em segundo plano
|
|
OsStr::new("--mute-audio"), // Silencia todo áudio (mais agressivo, mas garante silêncio)
|
|
OsStr::new("--disable-features=AutoplayIgnoreWebAudio"), // Desabilita autoplay de Web Audio
|
|
];
|
|
|
|
let options = LaunchOptions {
|
|
headless: true,
|
|
args: chrome_args,
|
|
..Default::default()
|
|
};
|
|
|
|
Browser::new(options)
|
|
.map_err(|e| anyhow::anyhow!("Falha ao criar browser: {}", e))
|
|
}
|
|
|
|
fn extract_paragraph_fallback(url: &str, html: &str) -> Option<ScrapedContent> {
|
|
use scraper::{Html, Selector};
|
|
|
|
let document = Html::parse_document(html);
|
|
let paragraph_selector = Selector::parse("p").ok()?;
|
|
let mut paragraphs = Vec::new();
|
|
|
|
for element in document.select(¶graph_selector) {
|
|
let text = element.text().collect::<Vec<_>>().join(" ");
|
|
let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
if normalized.chars().count() >= 100 {
|
|
paragraphs.push(normalized);
|
|
}
|
|
if paragraphs.len() >= 20 {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if paragraphs.len() < 3 {
|
|
return None;
|
|
}
|
|
|
|
let fallback_body = paragraphs.join("\n\n");
|
|
let title = fallback_title(html).unwrap_or_else(|| "Conteúdo externo".to_string());
|
|
|
|
Some(ScrapedContent {
|
|
title: title.clone(),
|
|
url: url.to_string(),
|
|
content: fallback_body.clone(),
|
|
markdown: format!(
|
|
"---\nTitle: {}\nSource: {}\n---\n\n{}",
|
|
title,
|
|
url,
|
|
fallback_body
|
|
),
|
|
})
|
|
}
|
|
|
|
fn fallback_title(html: &str) -> Option<String> {
|
|
use scraper::{Html, Selector};
|
|
|
|
let document = Html::parse_document(html);
|
|
|
|
if let Ok(selector) = Selector::parse("title") {
|
|
if let Some(node) = document.select(&selector).next() {
|
|
let text = node.text().collect::<Vec<_>>().join(" ").trim().to_string();
|
|
if !text.is_empty() {
|
|
return Some(text);
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Ok(selector) = Selector::parse("h1") {
|
|
if let Some(node) = document.select(&selector).next() {
|
|
let text = node.text().collect::<Vec<_>>().join(" ").trim().to_string();
|
|
if !text.is_empty() {
|
|
return Some(text);
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|