use crate::types::{DaedraError, DaedraResult, PageContent, PageLink, VisitPageArgs};
use backoff::{ExponentialBackoff, future::retry};
use dom_smoothie::Readability;
use lazy_static::lazy_static;
use reqwest::Client;
use scraper::{ElementRef, Html, Selector};
use std::collections::HashSet;
use std::time::Duration;
use tracing::{error, info, instrument, warn};
use url::Url;
const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_CONTENT_SIZE: usize = 10 * 1024 * 1024;
lazy_static! {
static ref CONTENT_SELECTORS: Vec<Selector> = vec![
Selector::parse("main").unwrap(),
Selector::parse("article").unwrap(),
Selector::parse("[role='main']").unwrap(),
Selector::parse("#content").unwrap(),
Selector::parse(".content").unwrap(),
Selector::parse(".main").unwrap(),
Selector::parse(".post").unwrap(),
Selector::parse(".article").unwrap(),
Selector::parse(".entry-content").unwrap(),
Selector::parse(".post-content").unwrap(),
];
static ref REMOVE_SELECTORS: Vec<Selector> = vec![
Selector::parse("script").unwrap(),
Selector::parse("style").unwrap(),
Selector::parse("noscript").unwrap(),
Selector::parse("header").unwrap(),
Selector::parse("footer").unwrap(),
Selector::parse("nav").unwrap(),
Selector::parse("[role='navigation']").unwrap(),
Selector::parse("aside").unwrap(),
Selector::parse(".sidebar").unwrap(),
Selector::parse("[role='complementary']").unwrap(),
Selector::parse(".nav").unwrap(),
Selector::parse(".menu").unwrap(),
Selector::parse(".header").unwrap(),
Selector::parse(".footer").unwrap(),
Selector::parse(".advertisement").unwrap(),
Selector::parse(".ads").unwrap(),
Selector::parse(".ad").unwrap(),
Selector::parse(".cookie-notice").unwrap(),
Selector::parse(".cookie-banner").unwrap(),
Selector::parse(".popup").unwrap(),
Selector::parse(".modal").unwrap(),
Selector::parse("[class*='cookie']").unwrap(),
Selector::parse("[class*='banner']").unwrap(),
Selector::parse("[class*='social']").unwrap(),
Selector::parse("[class*='share']").unwrap(),
Selector::parse("[class*='comment']").unwrap(),
];
static ref TITLE_SELECTOR: Selector = Selector::parse("title").unwrap();
static ref LINK_SELECTOR: Selector = Selector::parse("a[href]").unwrap();
static ref H1_SELECTOR: Selector = Selector::parse("h1").unwrap();
static ref H2_SELECTOR: Selector = Selector::parse("h2").unwrap();
static ref H3_SELECTOR: Selector = Selector::parse("h3").unwrap();
static ref H4_SELECTOR: Selector = Selector::parse("h4").unwrap();
static ref H5_SELECTOR: Selector = Selector::parse("h5").unwrap();
static ref H6_SELECTOR: Selector = Selector::parse("h6").unwrap();
static ref P_SELECTOR: Selector = Selector::parse("p").unwrap();
static ref UL_SELECTOR: Selector = Selector::parse("ul").unwrap();
static ref OL_SELECTOR: Selector = Selector::parse("ol").unwrap();
static ref LI_SELECTOR: Selector = Selector::parse("li").unwrap();
static ref PRE_SELECTOR: Selector = Selector::parse("pre").unwrap();
static ref CODE_SELECTOR: Selector = Selector::parse("code").unwrap();
static ref IMG_SELECTOR: Selector = Selector::parse("img").unwrap();
static ref BLOCKQUOTE_SELECTOR: Selector = Selector::parse("blockquote").unwrap();
static ref BOT_PROTECTION_SELECTORS: Vec<Selector> = vec![
Selector::parse("#challenge-running").unwrap(),
Selector::parse("#cf-challenge-running").unwrap(),
Selector::parse("#px-captcha").unwrap(),
Selector::parse("#ddos-protection").unwrap(),
Selector::parse("#waf-challenge-html").unwrap(),
Selector::parse(".cf-browser-verification").unwrap(),
];
}
const SUSPICIOUS_TITLES: &[&str] = &[
"security check",
"ddos protection",
"please wait",
"just a moment",
"attention required",
"access denied",
"blocked",
"captcha",
"verify you are human",
];
enum FetchedContent {
Html(String),
Pdf(String),
Binary { mime: String, size: usize },
}
fn is_skippable_href(href: &str) -> bool {
href.starts_with('#')
|| href.starts_with("javascript:")
|| href.starts_with("mailto:")
|| href.starts_with("tel:")
}
fn resolve_href(base: &Url, href: &str) -> Option<Url> {
if is_skippable_href(href) {
return None;
}
base.join(href).ok()
}
fn normalize_link_text(element: &ElementRef<'_>) -> Option<String> {
let text: String = element
.text()
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if text.is_empty() || text.len() <= 2 {
None
} else {
Some(text)
}
}
#[derive(Clone)]
pub struct FetchClient {
client: Client,
}
impl FetchClient {
pub fn new() -> DaedraResult<Self> {
let client = Client::builder()
.user_agent(USER_AGENT)
.timeout(REQUEST_TIMEOUT)
.gzip(true)
.brotli(true)
.redirect(reqwest::redirect::Policy::limited(10))
.build()
.map_err(DaedraError::HttpError)?;
Ok(Self { client })
}
#[instrument(skip(self), fields(url = %args.url))]
pub async fn fetch(&self, args: &VisitPageArgs) -> DaedraResult<PageContent> {
info!(url = %args.url, "Fetching page");
let parsed_url = validate_url(&args.url)?;
let fetched = self.fetch_with_retry(&args.url).await?;
match fetched {
FetchedContent::Html(html) => {
self.build_page_from_html(&html, &args.url, &parsed_url, args.selector.as_deref())
}
FetchedContent::Pdf(text) => Ok(FetchClient::build_page_from_pdf(&text, &args.url)),
FetchedContent::Binary { mime, size } => Err(DaedraError::ExtractionError(format!(
"Unsupported content type: {mime} ({size} bytes)"
))),
}
}
fn build_page_from_html(
&self,
html: &str,
url: &str,
base_url: &Url,
selector: Option<&str>,
) -> DaedraResult<PageContent> {
let document = Html::parse_document(html);
self.check_bot_protection(&document)?;
let title = self.extract_title(&document);
let content = self.extract_content(html, &document, url, selector)?;
let word_count = word_count(&content);
let links = if word_count >= 50 {
Some(self.extract_links(&document, base_url))
} else {
None
};
info!(
url = %url,
title = %title,
word_count = word_count,
"Page fetched successfully"
);
Ok(PageContent {
url: url.to_string(),
title,
content,
timestamp: chrono::Utc::now().to_rfc3339(),
word_count,
links,
})
}
fn build_page_from_pdf(text: &str, url: &str) -> PageContent {
let content = text.trim().to_string();
let word_count = word_count(&content);
let title = title_from_url(url);
info!(
url = %url,
title = %title,
word_count = word_count,
"PDF fetched successfully"
);
PageContent {
url: url.to_string(),
title,
content,
timestamp: chrono::Utc::now().to_rfc3339(),
word_count,
links: None,
}
}
async fn fetch_with_retry(&self, url: &str) -> DaedraResult<FetchedContent> {
let backoff = ExponentialBackoff {
max_elapsed_time: Some(Duration::from_secs(60)),
..Default::default()
};
let client = self.client.clone();
let url = url.to_string();
retry(backoff, || async {
let response = client.get(&url).send().await.map_err(|e| {
warn!(error = %e, url = %url, "Fetch request failed, retrying...");
backoff::Error::transient(DaedraError::HttpError(e))
})?;
classify_response_status(response.status(), &url)?;
if let Some(content_length) = response.content_length()
&& content_length as usize > MAX_CONTENT_SIZE
{
return Err(backoff::Error::permanent(DaedraError::FetchError(
"Content too large".to_string(),
)));
}
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let ct = normalize_content_type(&content_type);
if ct.contains("application/pdf") {
let bytes = response.bytes().await.map_err(|e| {
error!(error = %e, url = %url, "Failed to read response body");
backoff::Error::permanent(DaedraError::HttpError(e))
})?;
check_body_size(bytes.len())?;
return Ok(extract_pdf_content(&bytes)?);
}
if is_known_binary_content_type(&ct) {
let bytes = response.bytes().await.map_err(|e| {
error!(error = %e, url = %url, "Failed to read response body");
backoff::Error::permanent(DaedraError::HttpError(e))
})?;
check_body_size(bytes.len())?;
return Ok(FetchedContent::Binary {
mime: ct,
size: bytes.len(),
});
}
let bytes = response.bytes().await.map_err(|e| {
error!(error = %e, url = %url, "Failed to read response body");
backoff::Error::permanent(DaedraError::HttpError(e))
})?;
check_body_size(bytes.len())?;
classify_fetched_content(&content_type, &bytes).map_err(|e| backoff::Error::permanent(e))
})
.await
}
fn check_bot_protection(&self, document: &Html) -> DaedraResult<()> {
if has_bot_protection_element(document) || has_suspicious_title(document) {
return Err(DaedraError::BotProtectionDetected);
}
Ok(())
}
fn extract_title(&self, document: &Html) -> String {
text_from_selector(document, &TITLE_SELECTOR)
.or_else(|| text_from_selector(document, &H1_SELECTOR))
.unwrap_or_else(|| "Untitled".to_string())
}
fn select_content_html(
&self,
html: &str,
document: &Html,
url: &str,
selector: Option<&str>,
) -> DaedraResult<String> {
if let Some(sel) = selector {
Ok(self
.select_html_fragment(document, sel)?
.unwrap_or_else(|| self.select_body_html(document)))
} else if let Some(readability_html) = extract_with_readability(html, url) {
Ok(readability_html)
} else {
let fragment = self
.select_first_content_selector(document)
.unwrap_or_else(|| self.select_body_html(document));
let preview = clean_markdown(&html_to_markdown(&fragment));
if word_count(&preview) < 10 {
Ok(self.select_body_html(document))
} else {
Ok(fragment)
}
}
}
fn extract_content(
&self,
html: &str,
document: &Html,
url: &str,
selector: Option<&str>,
) -> DaedraResult<String> {
let content_html = self.select_content_html(html, document, url, selector)?;
let markdown = html_to_markdown(&content_html);
let cleaned = clean_markdown(&markdown);
if word_count(&cleaned) < 10 {
warn!("Extracted content is very short");
}
Ok(cleaned)
}
fn select_html_fragment(&self, document: &Html, sel: &str) -> DaedraResult<Option<String>> {
let custom_selector = Selector::parse(sel).map_err(|_| {
DaedraError::InvalidArguments(format!("Invalid CSS selector: {}", sel))
})?;
Ok(document
.select(&custom_selector)
.next()
.map(|el| el.html()))
}
fn select_first_content_selector(&self, document: &Html) -> Option<String> {
for selector in CONTENT_SELECTORS.iter() {
if let Some(element) = document.select(selector).next() {
return Some(element.html());
}
}
None
}
fn select_body_html(&self, document: &Html) -> String {
document
.select(&Selector::parse("body").unwrap())
.next()
.map(|el| el.html())
.unwrap_or_default()
}
fn extract_links(&self, document: &Html, base_url: &Url) -> Vec<PageLink> {
let mut links = Vec::new();
let mut seen_urls = HashSet::new();
for element in document.select(&LINK_SELECTOR) {
let Some(href) = element.value().attr("href") else {
continue;
};
let Some(resolved) = resolve_href(base_url, href) else {
continue;
};
if !seen_urls.insert(resolved.to_string()) {
continue;
}
let Some(text) = normalize_link_text(&element) else {
continue;
};
links.push(PageLink {
text,
url: resolved.to_string(),
});
}
links.truncate(50);
links
}
}
impl Default for FetchClient {
fn default() -> Self {
Self::new().expect("Failed to create default fetch client")
}
}
fn word_count(text: &str) -> usize {
text.split_whitespace().count()
}
fn validate_url(url: &str) -> DaedraResult<Url> {
let parsed_url = Url::parse(url).map_err(DaedraError::UrlParseError)?;
if !matches!(parsed_url.scheme(), "http" | "https") {
return Err(DaedraError::InvalidArguments(
"Only HTTP(S) URLs are supported".to_string(),
));
}
Ok(parsed_url)
}
fn is_retryable_status(status: u16) -> bool {
status == 429
}
fn classify_response_status(
status: reqwest::StatusCode,
url: &str,
) -> Result<(), backoff::Error<DaedraError>> {
if status.is_success() {
return Ok(());
}
warn!(status = %status, url = %url, "Fetch returned non-success status");
if is_retryable_status(status.as_u16()) {
return Err(backoff::Error::transient(DaedraError::RateLimitExceeded));
}
if status.as_u16() == 403 {
return Err(backoff::Error::permanent(DaedraError::BotProtectionDetected));
}
Err(backoff::Error::permanent(DaedraError::FetchError(format!(
"HTTP {}",
status
))))
}
fn normalize_content_type(content_type: &str) -> String {
content_type
.split(';')
.next()
.unwrap_or(content_type)
.trim()
.to_lowercase()
}
const BINARY_CONTENT_PREFIXES: &[&str] = &[
"image/",
"video/",
"audio/",
"application/vnd.openxmlformats-",
];
const BINARY_CONTENT_EXACT: &[&str] = &[
"application/zip",
"application/gzip",
"application/x-tar",
"application/octet-stream",
"application/vnd.ms-excel",
];
fn is_known_binary_content_type(content_type: &str) -> bool {
let ct = normalize_content_type(content_type);
BINARY_CONTENT_PREFIXES
.iter()
.any(|prefix| ct.starts_with(prefix))
|| BINARY_CONTENT_EXACT.iter().any(|exact| ct == *exact)
}
fn is_binary_mime(mime: &str) -> bool {
is_known_binary_content_type(mime)
|| mime == "application/pdf"
|| mime.starts_with("application/vnd.")
}
fn bytes_to_utf8_string(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
fn check_body_size(size: usize) -> DaedraResult<()> {
if size > MAX_CONTENT_SIZE {
return Err(DaedraError::FetchError("Content too large".to_string()));
}
Ok(())
}
fn extract_pdf_content(bytes: &[u8]) -> DaedraResult<FetchedContent> {
let text = pdf_extract::extract_text_from_mem(bytes)
.map_err(|e| DaedraError::ExtractionError(e.to_string()))?;
Ok(FetchedContent::Pdf(text))
}
fn has_bot_protection_element(document: &Html) -> bool {
BOT_PROTECTION_SELECTORS
.iter()
.any(|s| document.select(s).next().is_some())
}
fn has_suspicious_title(document: &Html) -> bool {
document
.select(&TITLE_SELECTOR)
.next()
.map_or(false, |el| {
let title = el.text().collect::<String>().to_lowercase();
SUSPICIOUS_TITLES.iter().any(|s| title.contains(s))
})
}
fn text_from_selector(document: &Html, selector: &Selector) -> Option<String> {
document
.select(selector)
.next()
.map(|el| el.text().collect::<String>().trim().to_string())
.filter(|t| !t.is_empty())
.map(|t| clean_title(&t))
}
fn classify_inferred_mime(mime: &str, bytes: &[u8]) -> Option<FetchedContent> {
match mime {
"application/pdf" => extract_pdf_content(bytes).ok(),
"text/html" | "application/xhtml+xml" => Some(FetchedContent::Html(bytes_to_utf8_string(bytes))),
m if is_binary_mime(m) => Some(FetchedContent::Binary {
mime: m.to_string(),
size: bytes.len(),
}),
m if m.starts_with("text/") => Some(FetchedContent::Html(bytes_to_utf8_string(bytes))),
_ => None,
}
}
fn classify_by_inference(kind: &infer::Type, bytes: &[u8]) -> Option<FetchedContent> {
classify_inferred_mime(kind.mime_type(), bytes)
}
fn classify_by_fallback(content_type: &str, bytes: &[u8]) -> DaedraResult<FetchedContent> {
let ct = normalize_content_type(content_type);
if ct.contains("text/html") {
return Ok(FetchedContent::Html(bytes_to_utf8_string(bytes)));
}
if std::str::from_utf8(bytes).is_ok() {
return Ok(FetchedContent::Html(bytes_to_utf8_string(bytes)));
}
Ok(FetchedContent::Binary {
mime: if ct.is_empty() {
"application/octet-stream".to_string()
} else {
ct
},
size: bytes.len(),
})
}
fn classify_fetched_content(content_type: &str, bytes: &[u8]) -> DaedraResult<FetchedContent> {
if let Some(kind) = infer::get(bytes) {
if let Some(content) = classify_by_inference(&kind, bytes) {
return Ok(content);
}
if kind.mime_type() == "application/pdf" {
return extract_pdf_content(bytes);
}
}
classify_by_fallback(content_type, bytes)
}
fn extract_with_readability(html: &str, url: &str) -> Option<String> {
let document_url = if url.is_empty() { None } else { Some(url) };
let mut readability = Readability::new(html, document_url, None).ok()?;
let article = readability.parse().ok()?;
if word_count(&article.text_content) >= 50 {
Some(article.content.to_string())
} else {
None
}
}
fn title_from_url(url: &str) -> String {
Url::parse(url)
.ok()
.and_then(|parsed| {
parsed
.path_segments()
.and_then(|segments| segments.filter(|s| !s.is_empty()).last())
.map(str::to_string)
})
.filter(|name| !name.is_empty())
.unwrap_or_else(|| url.to_string())
}
pub async fn fetch_page(args: &VisitPageArgs) -> DaedraResult<PageContent> {
let client = FetchClient::new()?;
client.fetch(args).await
}
pub fn is_valid_url(url: &str) -> bool {
match Url::parse(url) {
Ok(parsed) => matches!(parsed.scheme(), "http" | "https"),
Err(_) => false,
}
}
fn html_to_markdown(html: &str) -> String {
htmd::convert(html).unwrap_or_else(|_| html.to_string())
}
fn clean_markdown(markdown: &str) -> String {
markdown
.lines()
.filter(|line| {
let trimmed = line.trim();
trimmed != "-" && trimmed != "*" && trimmed != "+"
})
.fold(String::new(), |mut acc, line| {
let trimmed = line.trim();
if trimmed.is_empty() {
if !acc.ends_with("\n\n") {
acc.push('\n');
}
} else {
acc.push_str(trimmed);
acc.push('\n');
}
acc
})
.trim()
.to_string()
}
fn clean_title(title: &str) -> String {
let title = title
.split(" | ")
.next()
.unwrap_or(title)
.split(" - ")
.next()
.unwrap_or(title)
.split(" :: ")
.next()
.unwrap_or(title)
.split(" — ")
.next()
.unwrap_or(title);
title.trim().to_string()
}
#[cfg(test)]
impl FetchClient {
pub fn extract_content_from_html_for_tests(
&self,
html: &str,
selector: Option<&str>,
) -> DaedraResult<String> {
let document = Html::parse_document(html);
self.extract_content(html, &document, "", selector)
}
pub fn build_page_from_html_for_tests(
&self,
html: &str,
url: &str,
selector: Option<&str>,
) -> DaedraResult<PageContent> {
let parsed_url = validate_url(url)?;
self.build_page_from_html(html, url, &parsed_url, selector)
}
pub fn check_bot_protection_for_tests(&self, html: &str) -> DaedraResult<()> {
let document = Html::parse_document(html);
self.check_bot_protection(&document)
}
pub fn select_content_html_for_tests(
&self,
html: &str,
url: &str,
selector: Option<&str>,
) -> DaedraResult<String> {
let document = Html::parse_document(html);
self.select_content_html(html, &document, url, selector)
}
}
#[cfg(test)]
mod tests {
use super::*;
const CELIACHIA_FIXTURE: &str = include_str!("../../tests/fixtures/celiachia.html");
const CELIACHIA_ARTICLE_MARKER: &str = "indagine 2023 su";
#[test]
#[ignore = "bug #6 fixed: dom_smoothie now extracts full article"]
fn characterization_issue_6_celiachia_extract_content_low_word_count() {
let client = FetchClient::default();
let content = client
.extract_content_from_html_for_tests(CELIACHIA_FIXTURE, None)
.expect("extract");
let words = content.split_whitespace().count();
assert!(
words < 50,
"issue #6 characterization: expected <50 words, got {words}"
);
assert!(!content.contains(CELIACHIA_ARTICLE_MARKER));
}
#[test]
fn fixed_issue_6_celiachia_extract_content_full_article() {
let client = FetchClient::default();
let content = client
.extract_content_from_html_for_tests(CELIACHIA_FIXTURE, None)
.expect("extract");
let words = content.split_whitespace().count();
assert!(
words >= 50,
"issue #6 fix: expected >=50 words, got {words}"
);
assert!(content.contains(CELIACHIA_ARTICLE_MARKER));
}
#[test]
fn test_is_valid_url() {
assert!(is_valid_url("https://example.com"));
assert!(is_valid_url("http://example.com"));
assert!(!is_valid_url("ftp://example.com"));
assert!(!is_valid_url("javascript:alert(1)"));
assert!(!is_valid_url("not a url"));
}
#[test]
fn test_clean_title() {
assert_eq!(clean_title("Page Title | Site Name"), "Page Title");
assert_eq!(clean_title("Page Title - Site Name"), "Page Title");
assert_eq!(clean_title("Simple Title"), "Simple Title");
}
#[test]
fn test_clean_markdown() {
let input = "# Title\n\n\n\nParagraph\n\n\n\n\n\nAnother paragraph";
let expected = "# Title\n\nParagraph\n\nAnother paragraph";
assert_eq!(clean_markdown(input), expected);
}
#[test]
fn test_clean_markdown_excessive_blanks() {
let input = "Line one\n\n\n\n\nLine two";
assert_eq!(clean_markdown(input), "Line one\n\nLine two");
}
#[test]
fn test_clean_markdown_strips_list_markers() {
let input = "Content\n-\n*\n+\nMore";
assert_eq!(clean_markdown(input), "Content\nMore");
}
#[test]
fn test_clean_markdown_preserves_content() {
let input = "# Heading\n\nParagraph with **bold** text.";
assert_eq!(clean_markdown(input), "# Heading\n\nParagraph with **bold** text.");
}
#[test]
fn test_clean_markdown_empty_input() {
assert_eq!(clean_markdown(""), "");
}
#[test]
fn test_clean_markdown_only_blanks() {
assert_eq!(clean_markdown("\n\n\n\n"), "");
}
#[test]
fn test_html_to_markdown() {
let html = "<h1>Title</h1><p>Paragraph with <strong>bold</strong> text.</p>";
let markdown = html_to_markdown(html);
assert!(markdown.contains("Title"));
assert!(markdown.contains("Paragraph"));
assert!(markdown.contains("bold"));
}
#[test]
fn test_classify_fetched_content_html() {
let bytes = b"<html><body><p>Hello</p></body></html>";
let result = classify_fetched_content("text/html", bytes).unwrap();
assert!(matches!(result, FetchedContent::Html(_)));
}
#[test]
fn test_classify_fetched_content_pdf() {
let bytes = include_bytes!("../../tests/fixtures/minimal.pdf");
let result = classify_fetched_content("application/pdf", bytes).unwrap();
assert!(matches!(result, FetchedContent::Pdf(_)));
}
#[test]
fn test_classify_fetched_content_binary() {
let bytes: &[u8] = &[0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46];
let result = classify_fetched_content("image/jpeg", bytes).unwrap();
assert!(matches!(result, FetchedContent::Binary { .. }));
}
#[test]
fn test_classify_fetched_content_fallback_utf8() {
let bytes = b"plain text without magic bytes";
let result = classify_fetched_content("", bytes).unwrap();
assert!(matches!(result, FetchedContent::Html(_)));
}
#[test]
fn test_classify_fetched_content_fallback_binary() {
let bytes: &[u8] = &[0x80, 0x81, 0x82, 0x83];
let result = classify_fetched_content("", bytes).unwrap();
assert!(matches!(result, FetchedContent::Binary { .. }));
}
#[test]
fn test_normalize_content_type() {
assert_eq!(
normalize_content_type("text/html; charset=utf-8"),
"text/html"
);
}
#[test]
fn test_is_known_binary_content_type() {
assert!(is_known_binary_content_type("image/png"));
assert!(!is_known_binary_content_type("text/html"));
assert!(is_known_binary_content_type("application/zip"));
}
#[test]
fn test_check_body_size_ok() {
assert!(check_body_size(100).is_ok());
}
#[test]
fn test_check_body_size_too_large() {
assert!(check_body_size(MAX_CONTENT_SIZE + 1).is_err());
}
#[test]
fn test_extract_links() {
let html = r#"<html><body>
<a href="https://example.com/one">First Link</a>
<a href="/two">Second Link</a>
<a href="https://example.com/three">Third Link</a>
</body></html>"#;
let document = Html::parse_document(html);
let base = Url::parse("https://example.com/page").unwrap();
let client = FetchClient::default();
let links = client.extract_links(&document, &base);
assert_eq!(links.len(), 3);
assert_eq!(links[0].text, "First Link");
assert_eq!(links[0].url, "https://example.com/one");
assert_eq!(links[1].text, "Second Link");
assert_eq!(links[1].url, "https://example.com/two");
assert_eq!(links[2].text, "Third Link");
assert_eq!(links[2].url, "https://example.com/three");
}
#[test]
fn test_extract_links_skips_javascript() {
let html = r#"<html><body>
<a href="javascript:void(0)">JS</a>
<a href="mailto:test@example.com">Mail</a>
<a href="https://example.com/ok">OK Link</a>
</body></html>"#;
let document = Html::parse_document(html);
let base = Url::parse("https://example.com").unwrap();
let client = FetchClient::default();
let links = client.extract_links(&document, &base);
assert_eq!(links.len(), 1);
assert_eq!(links[0].url, "https://example.com/ok");
}
#[test]
fn test_extract_links_deduplicates() {
let html = r#"<html><body>
<a href="https://example.com/same">First</a>
<a href="https://example.com/same">Second</a>
</body></html>"#;
let document = Html::parse_document(html);
let base = Url::parse("https://example.com").unwrap();
let client = FetchClient::default();
let links = client.extract_links(&document, &base);
assert_eq!(links.len(), 1);
assert_eq!(links[0].url, "https://example.com/same");
}
#[test]
fn test_clean_markdown_removes_excessive_blanks() {
let input = "# Title\n\n\n\nParagraph\n\n\n\n\nAnother paragraph";
let expected = "# Title\n\nParagraph\n\nAnother paragraph";
assert_eq!(clean_markdown(input), expected);
}
#[test]
fn test_word_count() {
assert_eq!(word_count("one two three"), 3);
assert_eq!(word_count(" spaced words "), 2);
assert_eq!(word_count(""), 0);
}
#[test]
fn test_title_from_url() {
assert_eq!(
title_from_url("https://example.com/docs/guide.pdf"),
"guide.pdf"
);
assert_eq!(title_from_url("https://example.com/"), "https://example.com/");
}
#[test]
fn test_is_skippable_href_hash() {
assert!(is_skippable_href("#section"));
}
#[test]
fn test_is_skippable_href_javascript() {
assert!(is_skippable_href("javascript:void(0)"));
}
#[test]
fn test_is_skippable_href_mailto() {
assert!(is_skippable_href("mailto:a@b.com"));
}
#[test]
fn test_is_skippable_href_tel() {
assert!(is_skippable_href("tel:+123"));
}
#[test]
fn test_is_skippable_href_full_url() {
assert!(!is_skippable_href("https://example.com"));
}
#[test]
fn test_resolve_href() {
let base = Url::parse("https://example.com/page").unwrap();
assert_eq!(
resolve_href(&base, "/other").map(|u| u.to_string()),
Some("https://example.com/other".to_string())
);
assert_eq!(
resolve_href(&base, "relative").map(|u| u.to_string()),
Some("https://example.com/relative".to_string())
);
assert_eq!(resolve_href(&base, "#top"), None);
assert_eq!(resolve_href(&base, "javascript:alert(1)"), None);
}
#[test]
fn test_extract_with_readability() {
let words: String = (0..60)
.map(|i| format!("word{i}"))
.collect::<Vec<_>>()
.join(" ");
let html = format!(
"<html><head><title>Article</title></head><body><article><p>{words}</p></article></body></html>"
);
assert!(extract_with_readability(&html, "https://example.com/article").is_some());
assert!(extract_with_readability("<html><body>Hi</body></html>", "https://example.com").is_none());
}
#[test]
fn test_classify_by_inference_pdf() {
let bytes = include_bytes!("../../tests/fixtures/minimal.pdf");
let kind = infer::get(bytes).expect("pdf magic");
let result = classify_by_inference(&kind, bytes);
assert!(matches!(result, Some(FetchedContent::Pdf(_))));
}
#[test]
fn test_classify_by_inference_html() {
let bytes = b"<html><body><p>Hello</p></body></html>";
let kind = infer::get(bytes).expect("html infer match");
let result = classify_by_inference(&kind, bytes);
assert!(matches!(result, Some(FetchedContent::Html(_))));
}
#[test]
fn test_classify_by_inference_binary() {
let bytes: &[u8] = &[0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46];
let kind = infer::get(bytes).expect("jpeg magic");
let result = classify_by_inference(&kind, bytes);
assert!(matches!(result, Some(FetchedContent::Binary { .. })));
}
#[test]
fn test_classify_by_fallback_html_content_type() {
let bytes = b"not inferable content";
let result = classify_by_fallback("text/html", bytes).unwrap();
assert!(matches!(result, FetchedContent::Html(_)));
}
#[test]
fn test_classify_by_fallback_utf8_text() {
let bytes = b"plain text without magic bytes";
let result = classify_by_fallback("", bytes).unwrap();
assert!(matches!(result, FetchedContent::Html(_)));
}
#[test]
fn test_classify_by_fallback_binary() {
let bytes: &[u8] = &[0x80, 0x81, 0x82, 0x83];
let result = classify_by_fallback("", bytes).unwrap();
assert!(matches!(result, FetchedContent::Binary { .. }));
}
#[test]
fn test_build_page_from_pdf() {
let page = FetchClient::build_page_from_pdf(" hello world ", "https://example.com/doc.pdf");
assert_eq!(page.url, "https://example.com/doc.pdf");
assert_eq!(page.title, "doc.pdf");
assert_eq!(page.content, "hello world");
assert_eq!(page.word_count, 2);
assert!(page.links.is_none());
}
#[test]
fn test_validate_url_valid() {
let url = validate_url("https://example.com").unwrap();
assert_eq!(url.as_str(), "https://example.com/");
}
#[test]
fn test_validate_url_invalid_scheme() {
let err = validate_url("ftp://example.com").unwrap_err();
assert!(matches!(err, DaedraError::InvalidArguments(_)));
}
#[test]
fn test_validate_url_malformed() {
assert!(validate_url("not a url").is_err());
}
#[test]
fn test_is_known_binary_content_type_refactored() {
assert!(is_known_binary_content_type("image/png"));
assert!(is_known_binary_content_type("video/mp4"));
assert!(is_known_binary_content_type("audio/mp3"));
assert!(is_known_binary_content_type("application/zip"));
assert!(!is_known_binary_content_type("text/html"));
assert!(!is_known_binary_content_type("application/json"));
}
#[test]
fn test_build_page_from_html_basic() {
let html = r#"<html><head><title>Test Page</title></head><body>
<article><p>Hello world from the test article body content here.</p></article>
</body></html>"#;
let client = FetchClient::default();
let page = client
.build_page_from_html_for_tests(html, "https://example.com/page", None)
.unwrap();
assert_eq!(page.title, "Test Page");
assert_eq!(page.url, "https://example.com/page");
assert!(page.word_count > 0);
assert!(!page.content.is_empty());
}
#[test]
fn test_build_page_from_html_with_selector() {
let html = r#"<html><head><title>Site</title></head><body>
<div id="sidebar">Sidebar noise should not appear in output.</div>
<div id="main"><p>Main region only content for selector test case.</p></div>
</body></html>"#;
let client = FetchClient::default();
let page = client
.build_page_from_html_for_tests(html, "https://example.com", Some("#main"))
.unwrap();
assert!(page.content.contains("Main region only"));
assert!(!page.content.contains("Sidebar noise"));
}
#[test]
fn test_build_page_from_html_with_links() {
let words: String = (0..55)
.map(|i| format!("word{i}"))
.collect::<Vec<_>>()
.join(" ");
let html = format!(
r#"<html><head><title>Links Page</title></head><body>
<article><p>{words}</p>
<a href="https://example.com/one">First Link</a>
<a href="https://example.com/two">Second Link</a>
</article></body></html>"#
);
let client = FetchClient::default();
let page = client
.build_page_from_html_for_tests(&html, "https://example.com", None)
.unwrap();
assert!(page.word_count >= 50);
let links = page.links.expect("expected links for long pages");
assert!(!links.is_empty());
assert!(links.iter().any(|l| l.url.contains("example.com/one")));
}
#[test]
fn test_build_page_from_html_short_no_links() {
let html = r#"<html><head><title>Short</title></head><body>
<p>Brief page.</p>
<a href="https://example.com/link">Link</a>
</body></html>"#;
let client = FetchClient::default();
let page = client
.build_page_from_html_for_tests(html, "https://example.com/short", None)
.unwrap();
assert!(page.word_count < 50);
assert!(page.links.is_none());
}
#[test]
fn test_check_bot_protection_clean() {
let html = r#"<html><head><title>Normal Page</title></head><body><p>Hello</p></body></html>"#;
let client = FetchClient::default();
assert!(client.check_bot_protection_for_tests(html).is_ok());
}
#[test]
fn test_check_bot_protection_cf_challenge() {
let html = r#"<html><head><title>Checking</title></head><body>
<div id="cf-challenge-running"></div>
</body></html>"#;
let client = FetchClient::default();
let err = client.check_bot_protection_for_tests(html).unwrap_err();
assert!(matches!(err, DaedraError::BotProtectionDetected));
}
#[test]
fn test_check_bot_protection_suspicious_title() {
let html = r#"<html><head><title>Just a moment...</title></head><body></body></html>"#;
let client = FetchClient::default();
let err = client.check_bot_protection_for_tests(html).unwrap_err();
assert!(matches!(err, DaedraError::BotProtectionDetected));
}
#[test]
fn test_extract_content_with_readability() {
let words: String = (0..60)
.map(|i| format!("word{i}"))
.collect::<Vec<_>>()
.join(" ");
let html = format!(
"<html><head><title>Article</title></head><body><article><p>{words}</p></article></body></html>"
);
let client = FetchClient::default();
let content = client
.extract_content_from_html_for_tests(&html, None)
.unwrap();
assert!(word_count(&content) >= 50);
assert!(content.contains("word0"));
}
#[test]
fn test_extract_content_with_selector() {
let html = r#"<html><body>
<div class="noise">Ignored sidebar text here.</div>
<div id="target"><p>Selected fragment only.</p></div>
</body></html>"#;
let client = FetchClient::default();
let content = client
.extract_content_from_html_for_tests(html, Some("#target"))
.unwrap();
assert!(content.contains("Selected fragment only"));
assert!(!content.contains("Ignored sidebar"));
}
#[test]
fn test_extract_content_fallback_to_body() {
let html = r#"<html><body>
<div class="wrapper"><p>Body fallback content without article or main tags.</p></div>
</body></html>"#;
let client = FetchClient::default();
let content = client
.extract_content_from_html_for_tests(html, None)
.unwrap();
assert!(content.contains("Body fallback content"));
}
#[test]
fn test_select_content_html_with_selector() {
let html = r#"<html><body>
<div class="noise">Ignored sidebar text here.</div>
<div id="target"><p>Selected fragment only.</p></div>
</body></html>"#;
let client = FetchClient::default();
let content = client
.select_content_html_for_tests(html, "https://example.com", Some("#target"))
.unwrap();
assert!(content.contains("Selected fragment only"));
assert!(!content.contains("Ignored sidebar"));
}
#[test]
fn test_select_content_html_with_readability() {
let words: String = (0..60)
.map(|i| format!("word{i}"))
.collect::<Vec<_>>()
.join(" ");
let html = format!(
"<html><head><title>Article</title></head><body><article><p>{words}</p></article></body></html>"
);
let client = FetchClient::default();
let content = client
.select_content_html_for_tests(&html, "https://example.com/article", None)
.unwrap();
assert!(content.contains("word0"));
assert!(content.contains("<p>"));
}
#[test]
fn test_select_content_html_fallback_short() {
let html = r#"<html><body><div class="x"></div></body></html>"#;
let client = FetchClient::default();
let content = client
.select_content_html_for_tests(html, "https://example.com", None)
.unwrap();
assert!(content.contains("body") || content.contains("<div"));
}
#[test]
fn test_select_content_html_fallback_long() {
let html = r#"<html><body>
<div class="wrapper"><p>Body fallback content without article or main tags but enough words here.</p></div>
</body></html>"#;
let client = FetchClient::default();
let content = client
.select_content_html_for_tests(html, "https://example.com", None)
.unwrap();
assert!(content.contains("Body fallback content"));
assert!(content.contains("wrapper"));
}
#[test]
fn test_has_bot_protection_element_detected() {
let html = r#"<html><body><div id="cf-challenge-running"></div></body></html>"#;
let document = Html::parse_document(html);
assert!(has_bot_protection_element(&document));
}
#[test]
fn test_has_bot_protection_element_clean() {
let html = r#"<html><head><title>Normal Page</title></head><body><p>Hello</p></body></html>"#;
let document = Html::parse_document(html);
assert!(!has_bot_protection_element(&document));
}
#[test]
fn test_has_suspicious_title_detected() {
let html = r#"<html><head><title>Just a moment...</title></head><body></body></html>"#;
let document = Html::parse_document(html);
assert!(has_suspicious_title(&document));
}
#[test]
fn test_has_suspicious_title_clean() {
let html = r#"<html><head><title>My Page</title></head><body></body></html>"#;
let document = Html::parse_document(html);
assert!(!has_suspicious_title(&document));
}
#[test]
fn test_has_suspicious_title_no_title() {
let html = r#"<html><body><p>No title element</p></body></html>"#;
let document = Html::parse_document(html);
assert!(!has_suspicious_title(&document));
}
#[test]
fn test_text_from_selector_title() {
let html = r#"<html><head><title>Test</title></head><body></body></html>"#;
let document = Html::parse_document(html);
assert_eq!(
text_from_selector(&document, &TITLE_SELECTOR),
Some("Test".to_string())
);
}
#[test]
fn test_text_from_selector_empty() {
let html = r#"<html><head><title></title></head><body></body></html>"#;
let document = Html::parse_document(html);
assert_eq!(text_from_selector(&document, &TITLE_SELECTOR), None);
}
#[test]
fn test_text_from_selector_missing() {
let html = r#"<html><body><p>No title</p></body></html>"#;
let document = Html::parse_document(html);
assert_eq!(text_from_selector(&document, &TITLE_SELECTOR), None);
}
#[test]
fn test_classify_inferred_mime_text_html() {
let bytes = b"<!DOCTYPE html><html><body></body></html>";
let result = classify_inferred_mime("text/html", bytes);
assert!(matches!(result, Some(FetchedContent::Html(_))));
}
#[test]
fn test_classify_inferred_mime_application_pdf() {
let bytes = include_bytes!("../../tests/fixtures/minimal.pdf");
let result = classify_inferred_mime("application/pdf", bytes);
assert!(matches!(result, Some(FetchedContent::Pdf(_))));
}
#[test]
fn test_classify_inferred_mime_text_plain() {
let bytes = b"plain text content";
let result = classify_inferred_mime("text/plain", bytes);
assert!(matches!(result, Some(FetchedContent::Html(_))));
}
#[test]
fn test_classify_inferred_mime_text_csv() {
let bytes = b"name,value\na,1";
let result = classify_inferred_mime("text/csv", bytes);
assert!(matches!(result, Some(FetchedContent::Html(_))));
}
#[test]
fn test_classify_inferred_mime_text_xml() {
let bytes = b"<?xml version=\"1.0\"?><root/>";
let result = classify_inferred_mime("text/xml", bytes);
assert!(matches!(result, Some(FetchedContent::Html(_))));
}
#[test]
fn test_classify_inferred_mime_image_png() {
let bytes: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
let result = classify_inferred_mime("image/png", bytes);
assert!(matches!(
result,
Some(FetchedContent::Binary { mime, .. }) if mime == "image/png"
));
}
#[test]
fn test_classify_inferred_mime_application_zip() {
let bytes: &[u8] = &[0x50, 0x4B, 0x03, 0x04];
let result = classify_inferred_mime("application/zip", bytes);
assert!(matches!(
result,
Some(FetchedContent::Binary { mime, .. }) if mime == "application/zip"
));
}
#[test]
fn test_classify_inferred_mime_application_octet_stream() {
let bytes: &[u8] = &[0x00, 0x01, 0x02, 0x03];
let result = classify_inferred_mime("application/octet-stream", bytes);
assert!(matches!(
result,
Some(FetchedContent::Binary { mime, .. }) if mime == "application/octet-stream"
));
}
#[test]
fn test_classify_inferred_mime_audio_mpeg() {
let bytes: &[u8] = &[0xFF, 0xFB, 0x90, 0x00];
let result = classify_inferred_mime("audio/mpeg", bytes);
assert!(matches!(
result,
Some(FetchedContent::Binary { mime, .. }) if mime == "audio/mpeg"
));
}
}