use anyhow::Result;
use reqwest::Client;
use scraper::{Html, Selector};
use tokio_util::sync::CancellationToken;
const LIMIAR_CONTEUDO_MINIMO: usize = 200;
const LIMIAR_LINHA_MINIMA: usize = 20;
pub async fn extrair_conteudo_http(
cliente: &Client,
url: &str,
tamanho_max: usize,
token: &CancellationToken,
) -> Result<Option<(String, u32)>> {
if token.is_cancelled() {
anyhow::bail!("extração cancelada para {url:?}");
}
tracing::debug!(url, "iniciando extração de conteúdo HTTP");
let resposta = tokio::select! {
biased;
_ = token.cancelled() => {
anyhow::bail!("extração cancelada durante request de {url:?}");
}
resultado = cliente.get(url).send() => resultado?
};
if !resposta.status().is_success() {
tracing::debug!(url, status = %resposta.status(), "status HTTP não-sucesso — descartando");
return Ok(None);
}
let content_type = resposta
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
if !eh_html(&content_type) {
tracing::debug!(url, content_type, "Content-Type não é HTML — descartando");
return Ok(None);
}
let charset = extrair_charset(&content_type);
let bytes = tokio::select! {
biased;
_ = token.cancelled() => {
anyhow::bail!("extração cancelada durante leitura de body de {url:?}");
}
resultado = resposta.bytes() => resultado?
};
let tamanho_original = u32::try_from(bytes.len()).unwrap_or(u32::MAX);
tracing::debug!(url, tamanho = bytes.len(), "body baixado");
let html_utf8 = decodificar_para_utf8(&bytes, charset.as_deref());
let tamanho_max_local = tamanho_max;
let texto_limpo =
tokio::task::spawn_blocking(move || aplicar_readability(&html_utf8, tamanho_max_local))
.await
.map_err(|erro| anyhow::anyhow!("task de readability panicou: {erro}"))?;
if texto_limpo.len() < LIMIAR_CONTEUDO_MINIMO {
tracing::debug!(
url,
len = texto_limpo.len(),
"conteúdo extraído abaixo do limiar — sinalizando possível necessidade de Chrome"
);
return Ok(Some((String::new(), tamanho_original)));
}
tracing::debug!(url, tamanho_limpo = texto_limpo.len(), "extração concluída");
Ok(Some((texto_limpo, tamanho_original)))
}
fn eh_html(content_type: &str) -> bool {
let lower = content_type.to_ascii_lowercase();
lower.starts_with("text/html") || lower.starts_with("application/xhtml+xml")
}
fn extrair_charset(content_type: &str) -> Option<String> {
for parte in content_type.split(';') {
let trimmed = parte.trim();
if let Some(valor) = trimmed.strip_prefix("charset=") {
let limpo = valor.trim_matches(|c: char| c == '"' || c == '\'');
if !limpo.is_empty() {
return Some(limpo.to_ascii_lowercase());
}
}
}
None
}
pub fn decodificar_para_utf8(bytes: &[u8], charset: Option<&str>) -> String {
let label = charset.unwrap_or("utf-8");
if label == "utf-8" || label == "utf8" || label.is_empty() {
return String::from_utf8_lossy(bytes).into_owned();
}
match encoding_rs::Encoding::for_label(label.as_bytes()) {
Some(enc) => {
let (cow, _used, _had_errors) = enc.decode(bytes);
cow.into_owned()
}
None => {
tracing::debug!(
charset = label,
"label de charset desconhecido — fallback UTF-8 lossy"
);
String::from_utf8_lossy(bytes).into_owned()
}
}
}
fn aplicar_readability(html: &str, tamanho_max: usize) -> String {
let documento = Html::parse_document(html);
let seletores_container: [&str; 8] = [
"article",
"main",
"[role=\"main\"]",
".post-content",
".article-body",
".entry-content",
"#content",
".content",
];
let mut container_ref = None;
for sel_str in &seletores_container {
if let Ok(sel) = Selector::parse(sel_str) {
if let Some(primeiro) = documento.select(&sel).next() {
container_ref = Some(primeiro);
break;
}
}
}
let container = match container_ref {
Some(c) => c,
None => match Selector::parse("body")
.ok()
.and_then(|s| documento.select(&s).next())
{
Some(b) => b,
None => return String::new(),
},
};
let blocos = match Selector::parse("p, h1, h2, h3, h4, h5, h6, li, blockquote, pre, td, th") {
Ok(s) => s,
Err(_) => return String::new(),
};
let tags_proibidas: &[&str] = &[
"nav", "header", "footer", "aside", "script", "style", "noscript", "iframe", "svg", "form",
];
let classes_proibidas: &[&str] = &[
"sidebar",
"nav",
"menu",
"footer",
"header",
"ad",
"advertisement",
"social-share",
];
let roles_proibidas: &[&str] = &["navigation", "banner", "contentinfo"];
let mut linhas: Vec<String> = Vec::new();
for bloco in container.select(&blocos) {
if ancestral_eh_chrome(bloco, tags_proibidas, classes_proibidas, roles_proibidas) {
continue;
}
let texto: String = bloco
.text()
.collect::<Vec<_>>()
.join(" ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if !texto.is_empty() {
linhas.push(texto);
}
}
let conteudo: String = linhas
.into_iter()
.filter(|l| l.chars().count() >= LIMIAR_LINHA_MINIMA)
.collect::<Vec<_>>()
.join("\n");
truncar_em_palavra(&conteudo, tamanho_max)
}
fn ancestral_eh_chrome(
elemento: scraper::ElementRef<'_>,
tags: &[&str],
classes: &[&str],
roles: &[&str],
) -> bool {
let mut atual_no = elemento.parent();
while let Some(no) = atual_no {
if let Some(el) = scraper::ElementRef::wrap(no) {
let nome = el.value().name();
if tags.iter().any(|t| t.eq_ignore_ascii_case(nome)) {
return true;
}
if let Some(class_attr) = el.value().attr("class") {
for c in class_attr.split_ascii_whitespace() {
if classes
.iter()
.any(|proibida| c.eq_ignore_ascii_case(proibida))
{
return true;
}
}
}
if let Some(role) = el.value().attr("role") {
if roles.iter().any(|r| r.eq_ignore_ascii_case(role)) {
return true;
}
}
}
atual_no = no.parent();
}
false
}
fn truncar_em_palavra(texto: &str, tamanho_max: usize) -> String {
if tamanho_max == 0 {
return String::new();
}
let contado: usize = texto.chars().count();
if contado <= tamanho_max {
return texto.to_string();
}
let prefixo: String = texto.chars().take(tamanho_max).collect();
if let Some(pos) = prefixo.rfind(char::is_whitespace) {
return prefixo[..pos].trim_end().to_string();
}
prefixo
}
#[cfg(test)]
mod testes {
use super::*;
#[test]
fn eh_html_aceita_text_html_e_variantes() {
assert!(eh_html("text/html"));
assert!(eh_html("text/html; charset=utf-8"));
assert!(eh_html("application/xhtml+xml"));
assert!(eh_html("TEXT/HTML"));
}
#[test]
fn eh_html_rejeita_nao_html() {
assert!(!eh_html("application/pdf"));
assert!(!eh_html("image/png"));
assert!(!eh_html("application/json"));
assert!(!eh_html(""));
}
#[test]
fn extrair_charset_identifica_utf8() {
assert_eq!(
extrair_charset("text/html; charset=UTF-8"),
Some("utf-8".to_string())
);
assert_eq!(
extrair_charset("text/html; charset=\"iso-8859-1\""),
Some("iso-8859-1".to_string())
);
}
#[test]
fn extrair_charset_ausente_retorna_none() {
assert_eq!(extrair_charset("text/html"), None);
assert_eq!(extrair_charset(""), None);
}
#[test]
fn decodificar_utf8_puro() {
let bytes = "olá mundo".as_bytes();
let s = decodificar_para_utf8(bytes, None);
assert_eq!(s, "olá mundo");
let s2 = decodificar_para_utf8(bytes, Some("utf-8"));
assert_eq!(s2, "olá mundo");
}
#[test]
fn decodificar_latin1_para_utf8() {
let bytes: &[u8] = &[0xE1, 0x6C, 0x6F];
let s = decodificar_para_utf8(bytes, Some("iso-8859-1"));
assert_eq!(s, "álo");
}
#[test]
fn decodificar_windows1252_para_utf8() {
let bytes: &[u8] = &[0xE7];
let s = decodificar_para_utf8(bytes, Some("windows-1252"));
assert_eq!(s, "ç");
}
#[test]
fn decodificar_charset_desconhecido_cai_em_utf8_lossy() {
let bytes = "teste".as_bytes();
let s = decodificar_para_utf8(bytes, Some("charset-que-nao-existe"));
assert_eq!(s, "teste");
}
#[test]
fn truncar_em_palavra_preserva_fronteira() {
let texto = "uma frase qualquer com várias palavras";
let t = truncar_em_palavra(texto, 10);
assert!(t.len() <= 10);
assert!(!t.ends_with(' '));
assert!(
texto.starts_with(&t),
"truncado ({t:?}) deve ser prefixo do original"
);
}
#[test]
fn truncar_em_palavra_texto_curto_retorna_original() {
assert_eq!(truncar_em_palavra("oi", 100), "oi");
assert_eq!(truncar_em_palavra("", 100), "");
}
#[test]
fn truncar_em_palavra_sem_whitespace_corta_hard() {
let t = truncar_em_palavra("palavraSemEspacoNenhum", 10);
assert_eq!(t.chars().count(), 10);
}
#[test]
fn readability_extrai_artigo_simples() {
let html = r#"<html><body>
<nav><a href="/">Menu</a></nav>
<article>
<h1>Título do Artigo</h1>
<p>Este é o primeiro parágrafo do artigo com pelo menos vinte caracteres de conteúdo substantivo.</p>
<p>Segundo parágrafo também com conteúdo suficiente para passar do limiar de linha mínima.</p>
</article>
<footer>Copyright</footer>
</body></html>"#;
let texto = aplicar_readability(html, 1000);
assert!(texto.contains("primeiro parágrafo"));
assert!(texto.contains("Segundo parágrafo"));
assert!(!texto.contains("Menu"));
assert!(!texto.contains("Copyright"));
}
#[test]
fn readability_usa_main_quando_nao_ha_article() {
let html = r#"<html><body>
<header>Cabeçalho irrelevante</header>
<main>
<p>Conteúdo principal via tag main, com mais de vinte caracteres de texto útil aqui.</p>
<p>Outro parágrafo relevante com conteúdo suficiente para não ser descartado.</p>
</main>
</body></html>"#;
let texto = aplicar_readability(html, 1000);
assert!(texto.contains("Conteúdo principal"));
assert!(texto.contains("Outro parágrafo"));
assert!(!texto.contains("Cabeçalho"));
}
#[test]
fn readability_remove_script_style_nav() {
let html = r#"<html><body>
<nav><p>Este parágrafo dentro da nav deve ser descartado porque é chrome.</p></nav>
<article>
<script>var x = 1;</script>
<style>.a { color: red; }</style>
<p>Parágrafo legítimo dentro de article com conteúdo o bastante para passar o limiar.</p>
</article>
</body></html>"#;
let texto = aplicar_readability(html, 1000);
assert!(texto.contains("Parágrafo legítimo"));
assert!(!texto.contains("dentro da nav"));
assert!(!texto.contains("var x = 1"));
assert!(!texto.contains("color: red"));
}
#[test]
fn readability_trunca_em_tamanho_max() {
let conteudo_longo = "Parágrafo um com pelo menos vinte caracteres aqui.\n".repeat(100);
let html = format!("<html><body><article><p>{conteudo_longo}</p></article></body></html>");
let texto = aplicar_readability(&html, 200);
assert!(texto.chars().count() <= 200);
}
#[test]
fn readability_retorna_vazio_sem_conteudo_suficiente() {
let html = r#"<html><body>
<nav>Menu curto</nav>
<footer>Rodapé breve.</footer>
</body></html>"#;
let texto = aplicar_readability(html, 1000);
assert!(
texto.len() < LIMIAR_CONTEUDO_MINIMO,
"sem conteúdo substantivo esperado, obtido: {texto:?}"
);
}
}