use scraper::{Html, Selector};
pub fn extract_main_text(html: &str) -> String {
let doc = Html::parse_document(html);
let root = ["article", "main", "body"].iter().find_map(|tag| {
let sel = Selector::parse(tag).ok()?;
doc.select(&sel).next()
});
let Some(root) = root else {
return String::new();
};
let skip = ["script", "style", "noscript", "nav", "header", "footer"];
let block_tags = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "pre"];
let block_sel = Selector::parse("p, h1, h2, h3, h4, h5, h6, li, blockquote, pre").unwrap();
let mut blocks: Vec<String> = Vec::new();
for el in root.select(&block_sel) {
let mut excluded = false;
let mut cur = el.parent();
while let Some(node) = cur {
if let Some(e) = scraper::ElementRef::wrap(node) {
let name = e.value().name();
if skip.contains(&name) || block_tags.contains(&name) {
excluded = true;
break;
}
}
cur = node.parent();
}
if excluded {
continue;
}
let block = if el.value().name() == "pre" {
el.text().collect::<String>().trim_matches('\n').trim_end().to_string()
} else {
el.text().collect::<Vec<_>>().join(" ").split_whitespace().collect::<Vec<_>>().join(" ")
};
if !block.is_empty() {
blocks.push(block);
}
}
blocks.join("\n\n")
}
pub async fn fetch_url(client: &reqwest::Client, url: &str) -> std::io::Result<String> {
let resp = client
.get(url)
.send()
.await
.and_then(|r| r.error_for_status())
.map_err(|e| std::io::Error::other(format!("fetch failed for {url}: {e}")))?;
resp.text()
.await
.map_err(|e| std::io::Error::other(format!("read body failed for {url}: {e}")))
}
pub async fn fetch_url_capped(client: &reqwest::Client, url: &str, max_bytes: u64) -> std::io::Result<String> {
let mut resp = client.get(url).send().await.map_err(|e| std::io::Error::other(format!("fetch {url}: {e}")))?;
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = resp.chunk().await.map_err(|e| std::io::Error::other(format!("read {url}: {e}")))? {
let room = (max_bytes as usize).saturating_sub(buf.len());
if room == 0 { break; }
buf.extend_from_slice(&chunk[..room.min(chunk.len())]);
if buf.len() as u64 >= max_bytes { break; }
}
Ok(String::from_utf8_lossy(&buf).into_owned())
}
#[cfg(test)]
mod tests {
use super::extract_main_text;
use super::fetch_url_capped;
#[tokio::test]
async fn fetch_url_gets_body_from_localhost() {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let handle = std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf); let body = "<html><body><p>Fetched body text.</p></body></html>";
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/html\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(resp.as_bytes());
let _ = stream.flush();
}
});
let client = crate::net::build_client(None).unwrap();
let url = format!("http://127.0.0.1:{port}/");
let html = super::fetch_url(&client, &url).await.unwrap();
handle.join().unwrap();
assert!(html.contains("Fetched body text."));
}
#[tokio::test]
async fn fetch_url_errors_on_bad_host() {
let client = crate::net::build_client(None).unwrap();
let res = super::fetch_url(&client, "http://127.0.0.1:1/").await;
assert!(res.is_err());
}
#[test]
fn extracts_article_text_and_drops_noise() {
let html = r#"
<html><head><style>.x{}</style><title>t</title></head>
<body>
<nav>menu junk</nav>
<article>
<h1>The Title</h1>
<p>Hello world.</p>
<script>evil()</script>
<p>Second paragraph.</p>
</article>
<footer>footer junk</footer>
</body></html>
"#;
let out = extract_main_text(html);
assert_eq!(out, "The Title\n\nHello world.\n\nSecond paragraph.");
}
#[test]
fn falls_back_to_body_without_article() {
let html = "<html><body><p>Only body.</p><p>Two.</p></body></html>";
assert_eq!(extract_main_text(html), "Only body.\n\nTwo.");
}
#[test]
fn empty_on_no_text() {
assert_eq!(extract_main_text("<html><body></body></html>"), "");
}
#[test]
fn preserves_pre_whitespace() {
let html = "<html><body><article><pre>fn main() {\n let x = 1;\n}</pre></article></body></html>";
let out = super::extract_main_text(html);
assert_eq!(out, "fn main() {\n let x = 1;\n}");
}
#[test]
fn dedupes_nested_block_text() {
let html = "<html><body><article><blockquote><p>quoted line</p></blockquote></article></body></html>";
assert_eq!(super::extract_main_text(html), "quoted line");
}
#[tokio::test]
async fn fetch_url_capped_caps_bytes() {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let big = "x".repeat(10_000);
let resp = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}", big.len(), big);
std::thread::spawn(move || { if let Ok((mut s,_)) = listener.accept() { let mut b=[0u8;1024]; let _=s.read(&mut b); let _=s.write_all(resp.as_bytes()); }});
let client = crate::net::build_client(None).unwrap();
let body = fetch_url_capped(&client, &format!("http://{addr}/"), 100).await.unwrap();
assert!(body.len() <= 100, "capped, got {}", body.len());
}
}