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
@@ -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