use crate::models::models::{
ContentChunk, ContentChunkType, ExtractedMetadata, FormField, FormInfo, Heading, ImageInfo,
LinkInfo, MediaInfo, ProcessedDocument, Result, StructuredData,
};
use crate::parser::utils::{
get_attribute, get_text_content, has_attribute, select_all, select_all_within, select_first,
};
#[allow(unused_imports)] use crate::utils::html::{
extract_meta_content, extract_prefixed_meta_tags, extract_script_content_by_type,
};
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use tl::VDom;
use url::Url;
fn deduplicate_text(text: &str) -> String {
if text.trim().is_empty() {
return String::new();
}
let mut sentences = Vec::new();
let mut current = String::new();
for ch in text.chars() {
match ch {
'.' | '!' | '?' | '\n' if !current.trim().is_empty() => {
let trimmed = current.trim();
if trimmed.len() >= 10 {
sentences.push(trimmed.to_string());
}
current.clear();
}
_ => {
current.push(ch);
}
}
}
if !current.trim().is_empty() {
let trimmed = current.trim();
if trimmed.len() >= 10 {
sentences.push(trimmed.to_string());
}
}
let mut phrase_counts: HashMap<String, usize> = HashMap::new();
for sentence in &sentences {
let words: Vec<&str> = sentence.split_whitespace().collect();
for len in 2..=3.min(words.len()) {
for window in words.windows(len) {
let phrase = window.join(" ").to_lowercase();
*phrase_counts.entry(phrase).or_insert(0) += 1;
}
}
}
let spam_phrases: HashSet<String> = phrase_counts
.iter()
.filter(|(phrase, &count)| {
let word_count = phrase.split_whitespace().count();
count > 12 && word_count <= 3
})
.map(|(phrase, _)| phrase.clone())
.collect();
let mut seen = HashSet::new();
let mut unique_sentences = Vec::new();
for sentence in sentences {
let trimmed = sentence.trim();
if trimmed.len() < 15 {
continue;
}
let normalized = trimmed.to_lowercase().split_whitespace().collect::<Vec<_>>().join(" ");
let contains_spam = spam_phrases.iter().any(|phrase| normalized.contains(phrase));
if contains_spam {
continue;
}
if !seen.insert(normalized) {
continue;
}
unique_sentences.push(trimmed.to_string());
}
if unique_sentences.is_empty() {
return String::new();
}
unique_sentences
.iter()
.map(|s| {
let s = s.trim();
if s.ends_with(&['.', '!', '?', ':'][..]) {
s.to_string()
} else {
format!("{}.", s)
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn calculate_repetition_ratio(text: &str) -> f32 {
if text.trim().is_empty() {
return 1.0;
}
let sentences: Vec<&str> = text
.split(&['.', '!', '?', '\n'][..])
.filter(|s| s.trim().len() > 10)
.collect();
if sentences.is_empty() {
return 1.0;
}
let unique_sentences: HashSet<String> = sentences
.iter()
.map(|s| s.trim().to_lowercase().split_whitespace().collect::<Vec<_>>().join(" "))
.collect();
unique_sentences.len() as f32 / sentences.len() as f32
}
pub trait Extractor {
fn extract_global_metadata(&self, dom: &VDom, url: &str) -> Result<ExtractedMetadata>;
fn extract_content(
&self,
dom: &VDom,
url: &str,
metadata: &ExtractedMetadata,
) -> Result<ProcessedDocument>;
}
#[derive(Debug, Default)]
pub struct DefaultExtractor;
impl DefaultExtractor {
pub fn new() -> Self {
Self
}
fn extract_meta_content(&self, dom: &VDom, name: &str) -> Option<String> {
crate::utils::html::extract_meta_content(dom, name)
}
fn extract_og_tags(&self, dom: &VDom) -> HashMap<String, String> {
extract_prefixed_meta_tags(dom, "og:", "property")
}
fn extract_twitter_tags(&self, dom: &VDom) -> HashMap<String, String> {
extract_prefixed_meta_tags(dom, "twitter:", "name")
}
fn extract_structured_data(&self, dom: &VDom) -> StructuredData {
let mut structured_data = StructuredData::default();
structured_data.json_ld = extract_script_content_by_type(dom, "application/ld+json");
for element in select_all(dom, "[typeof], [property]") {
if let Some(typeof_attr) = get_attribute(element, "typeof") {
structured_data.rdfa_items.push(typeof_attr);
}
}
for element in select_all(dom, "[itemtype], [itemprop]") {
if let Some(itemtype) = get_attribute(element, "itemtype") {
structured_data.microdata.push(itemtype);
}
}
structured_data
}
fn extract_feed_urls(&self, dom: &VDom) -> Vec<String> {
let mut feeds = Vec::new();
for link in select_all(
dom,
"link[type='application/rss+xml'], link[type='application/atom+xml']",
) {
if let Some(href) = get_attribute(link, "href") {
feeds.push(href);
}
}
feeds
}
fn extract_favicons(&self, dom: &VDom) -> Vec<String> {
let mut favicons = Vec::new();
for link in select_all(dom, "link[rel*='icon']") {
if let Some(href) = get_attribute(link, "href") {
favicons.push(href);
}
}
favicons
}
fn extract_headings(&self, dom: &VDom) -> Vec<Heading> {
let mut headings = Vec::new();
for i in 1..=6 {
for heading in select_all(dom, &format!("h{}", i)) {
let text_content = get_text_content(heading, dom);
let trimmed_text = text_content.trim();
if !trimmed_text.is_empty() {
headings.push(Heading {
level: i as u8,
text: trimmed_text.to_owned(), id: get_attribute(heading, "id"),
});
}
}
}
headings
}
fn resolve_url<'a>(&self, base_url_parsed: Option<&Url>, href: &'a str) -> Cow<'a, str> {
if let Some(base) = base_url_parsed {
match base.join(href) {
Ok(resolved) => Cow::Owned(resolved.to_string()),
Err(_) => Cow::Borrowed(href), }
} else {
Cow::Borrowed(href) }
}
fn extract_links(&self, dom: &VDom, base_url: &str) -> Vec<LinkInfo> {
let mut links = Vec::new();
let base_url_parsed = Url::parse(base_url).ok();
for link in select_all(dom, "a[href]") {
if let Some(raw_href) = get_attribute(link, "href") {
let resolved_href = self
.resolve_url(base_url_parsed.as_ref(), &raw_href)
.into_owned();
let text_content = get_text_content(link, dom);
let text = if text_content.trim().is_empty() {
String::new()
} else {
text_content.trim().to_owned()
};
let title = get_attribute(link, "title");
let rel = get_attribute(link, "rel").unwrap_or_default();
let is_nofollow = rel
.split_whitespace()
.any(|attr| attr.eq_ignore_ascii_case("nofollow"));
let rel_attributes: Vec<String> = if rel.is_empty() {
Vec::new()
} else {
rel.split_whitespace().map(str::to_lowercase).collect()
};
let is_internal = if let (Some(base), Ok(link_url)) =
(&base_url_parsed, Url::parse(&resolved_href))
{
base.host() == link_url.host()
} else {
raw_href.starts_with('/')
|| raw_href.starts_with('#')
|| !raw_href.contains("://")
};
links.push(LinkInfo {
raw_href,
resolved_href,
text,
title,
rel_attributes,
is_internal,
is_nofollow,
});
}
}
links
}
fn extract_images(&self, dom: &VDom, base_url: &str) -> Vec<ImageInfo> {
let mut images = Vec::new();
let base_url_parsed = Url::parse(base_url).ok();
for img in select_all(dom, "img") {
if let Some(src_raw) = get_attribute(img, "src") {
let src = self
.resolve_url(base_url_parsed.as_ref(), &src_raw)
.into_owned();
let alt = get_attribute(img, "alt").unwrap_or_default();
let title = get_attribute(img, "title");
let width = get_attribute(img, "width").and_then(|w| w.parse::<u32>().ok());
let height = get_attribute(img, "height").and_then(|h| h.parse::<u32>().ok());
images.push(ImageInfo {
src,
alt,
title,
width,
height,
});
}
}
images
}
fn extract_forms(&self, dom: &VDom, base_url: &str) -> Vec<FormInfo> {
let mut forms = Vec::new();
let base_url_parsed = Url::parse(base_url).ok();
for form in select_all(dom, "form") {
let action_raw = get_attribute(form, "action");
let action = if let (Some(base), Some(href)) = (&base_url_parsed, &action_raw) {
base.join(href).map(|u| u.to_string()).ok()
} else {
action_raw
};
let method = get_attribute(form, "method").map(|m| m.to_uppercase());
let mut fields = Vec::new();
for input in select_all_within(form, "input", dom) {
let name = get_attribute(input, "name");
let field_type = get_attribute(input, "type");
let required = has_attribute(input, "required");
let placeholder = get_attribute(input, "placeholder");
fields.push(FormField {
name,
field_type,
required,
placeholder,
});
}
for textarea in select_all_within(form, "textarea", dom) {
let name = get_attribute(textarea, "name");
let required = has_attribute(textarea, "required");
let placeholder = get_attribute(textarea, "placeholder");
fields.push(FormField {
name,
field_type: Some("textarea".into()),
required,
placeholder,
});
}
for select_tag in select_all_within(form, "select", dom) {
let name = get_attribute(select_tag, "name");
let required = has_attribute(select_tag, "required");
fields.push(FormField {
name,
field_type: Some("select".into()),
required,
placeholder: None,
});
}
forms.push(FormInfo {
action,
method,
fields,
});
}
forms
}
fn extract_media(&self, dom: &VDom, base_url: &str) -> Vec<MediaInfo> {
let mut media = Vec::new();
let base_url_parsed = Url::parse(base_url).ok();
for video in select_all(dom, "video") {
if let Some(src_raw) = get_attribute(video, "src") {
let src = self
.resolve_url(base_url_parsed.as_ref(), &src_raw)
.into_owned();
media.push(MediaInfo {
url: src,
media_type: "video".to_owned(),
title: get_attribute(video, "title"),
thumbnail_url: get_attribute(video, "poster").map(|poster_raw| {
self.resolve_url(base_url_parsed.as_ref(), &poster_raw)
.into_owned()
}),
});
}
}
for audio in select_all(dom, "audio") {
if let Some(src_raw) = get_attribute(audio, "src") {
let src = self
.resolve_url(base_url_parsed.as_ref(), &src_raw)
.into_owned();
media.push(MediaInfo {
url: src,
media_type: "audio".to_owned(),
title: get_attribute(audio, "title"),
thumbnail_url: None,
});
}
}
media
}
fn extract_content_chunks(&self, dom: &VDom) -> (String, Vec<ContentChunk>) {
let mut main_content = String::new();
let mut chunks = Vec::new();
let mut position = 0;
for i in 1..=6 {
for heading in select_all(dom, &format!("h{}", i)) {
let text_content = get_text_content(heading, dom);
let trimmed_text = text_content.trim();
if !trimmed_text.is_empty() {
main_content.push_str(trimmed_text);
main_content.push(' ');
chunks.push(ContentChunk {
text: trimmed_text.to_owned(),
chunk_type: ContentChunkType::Title,
position,
confidence: 0.9, });
position += 1;
}
}
}
for p in select_all(dom, "p") {
let text_content = get_text_content(p, dom);
let trimmed_text = text_content.trim();
if !trimmed_text.is_empty() {
main_content.push_str(trimmed_text);
main_content.push(' ');
chunks.push(ContentChunk {
text: trimmed_text.to_owned(),
chunk_type: ContentChunkType::Paragraph,
position,
confidence: 0.8,
});
position += 1;
}
}
for element_type in &["div", "section", "article"] {
for element in select_all(dom, element_type) {
let text_content = get_text_content(element, dom);
let trimmed_text = text_content.trim();
if !trimmed_text.is_empty() && trimmed_text.len() > 20 && trimmed_text.len() < 1000
{
let has_substantial_child_content =
select_all_within(element, "p, h1, h2, h3, h4, h5, h6, li", dom)
.iter()
.any(|child| get_text_content(child, dom).trim().len() > 10);
if !has_substantial_child_content {
main_content.push_str(trimmed_text);
main_content.push(' ');
chunks.push(ContentChunk {
text: trimmed_text.to_owned(),
chunk_type: ContentChunkType::Unknown, position,
confidence: 0.7,
});
position += 1;
}
}
}
}
for li in select_all(dom, "li") {
let text_content = get_text_content(li, dom);
let trimmed_text = text_content.trim();
if !trimmed_text.is_empty() && trimmed_text.len() > 5 {
main_content.push_str(trimmed_text);
main_content.push(' ');
chunks.push(ContentChunk {
text: trimmed_text.to_owned(),
chunk_type: ContentChunkType::ListItem,
position,
confidence: 0.6,
});
position += 1;
}
}
for blockquote in select_all(dom, "blockquote") {
let text_content = get_text_content(blockquote, dom);
let trimmed_text = text_content.trim();
if !trimmed_text.is_empty() {
let text = trimmed_text.to_string();
main_content.push_str(&text);
main_content.push(' ');
chunks.push(ContentChunk {
text,
chunk_type: ContentChunkType::Quote,
position,
confidence: 0.7,
});
position += 1;
}
}
for cell_type in &["td", "th"] {
for cell in select_all(dom, cell_type) {
let text_content = get_text_content(cell, dom);
let trimmed_text = text_content.trim();
if !trimmed_text.is_empty() && trimmed_text.len() > 3 {
let text = trimmed_text.to_string();
main_content.push_str(&text);
main_content.push(' ');
chunks.push(ContentChunk {
text,
chunk_type: ContentChunkType::Table,
position,
confidence: 0.5,
});
position += 1;
}
}
}
for element_type in &["figcaption", "address", "details", "summary"] {
for element in select_all(dom, element_type) {
let text_content = get_text_content(element, dom);
let trimmed_text = text_content.trim();
if !trimmed_text.is_empty() && trimmed_text.len() > 5 {
main_content.push_str(trimmed_text);
main_content.push(' ');
chunks.push(ContentChunk {
text: trimmed_text.to_owned(),
chunk_type: ContentChunkType::Unknown, position,
confidence: 0.6,
});
position += 1;
}
}
}
let filtered_chunks = crate::content::filter_content_chunks(chunks);
let mut filtered_content = String::new();
for chunk in &filtered_chunks {
filtered_content.push_str(&chunk.text);
filtered_content.push(' ');
}
let final_content = filtered_content.trim();
(
if final_content.is_empty() {
String::new()
} else {
final_content.to_owned()
},
filtered_chunks,
)
}
fn estimate_reading_time(&self, text: &str) -> f32 {
let word_count = text.split_whitespace().count();
word_count as f32 / 225.0
}
}
impl Extractor for DefaultExtractor {
fn extract_global_metadata(&self, dom: &VDom, _url: &str) -> Result<ExtractedMetadata> {
let title = select_first(dom, "title")
.map(|node| get_text_content(node, dom).to_string())
.unwrap_or_default();
let meta_description = self.extract_meta_content(dom, "description");
let canonical_url =
select_first(dom, "link[rel='canonical']").and_then(|node| get_attribute(node, "href"));
let meta_keywords = self.extract_meta_content(dom, "keywords");
let author = self.extract_meta_content(dom, "author");
let publish_date = self
.extract_meta_content(dom, "publish_date")
.or_else(|| self.extract_meta_content(dom, "article:published_time"));
let language = select_first(dom, "html")
.and_then(|node| get_attribute(node, "lang"))
.or_else(|| self.extract_meta_content(dom, "language"));
let robots = self.extract_meta_content(dom, "robots");
let viewport = self.extract_meta_content(dom, "viewport");
let charset = select_first(dom, "meta[charset]")
.and_then(|node| get_attribute(node, "charset"))
.or_else(|| self.extract_meta_content(dom, "charset"));
let og_tags = self.extract_og_tags(dom);
let twitter_tags = self.extract_twitter_tags(dom);
let feed_urls = self.extract_feed_urls(dom);
let favicons = self.extract_favicons(dom);
let structured_data = self.extract_structured_data(dom);
Ok(ExtractedMetadata {
title,
meta_description,
canonical_url,
meta_keywords,
author,
publish_date,
language,
robots,
viewport,
charset,
og_tags,
twitter_tags,
feed_urls,
favicons,
structured_data,
})
}
fn extract_content(
&self,
dom: &VDom,
url: &str,
metadata: &ExtractedMetadata,
) -> Result<ProcessedDocument> {
let headings = self.extract_headings(dom);
let links = self.extract_links(dom, url);
let images = self.extract_images(dom, url);
let media = self.extract_media(dom, url);
let forms = self.extract_forms(dom, url);
let (content_text, content_chunks) = self.extract_content_chunks(dom);
let repetition_before = calculate_repetition_ratio(&content_text);
let deduplicated_text = deduplicate_text(&content_text);
let repetition_after = calculate_repetition_ratio(&deduplicated_text);
#[cfg(debug_assertions)]
{
if repetition_before < 0.5 {
eprintln!(
"š§ Deduplication applied: {:.1}% ā {:.1}% unique sentences",
repetition_before * 100.0,
repetition_after * 100.0
);
}
}
let cleaned_text = deduplicated_text;
let estimated_reading_time = self.estimate_reading_time(&cleaned_text);
Ok(ProcessedDocument {
url: url.to_string(),
title: metadata.title.clone(),
cleaned_text,
headings,
links,
images,
forms,
media,
content_chunks,
language_detected: metadata.language.clone(),
estimated_reading_time,
})
}
}
pub fn create_extractor() -> DefaultExtractor {
DefaultExtractor::new()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::models::ExtractedMetadata;
use crate::parser::Parser;
use std::collections::HashMap;
const SAMPLE_HTML: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<title>Sample Page Title</title>
<meta name="description" content="This is a sample page description">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta property="og:title" content="OG Title">
<meta property="og:description" content="OG Description">
<meta name="twitter:card" content="summary">
<link rel="canonical" href="https://example.com/page">
<link rel="icon" href="/favicon.ico">
</head>
<body>
<header>
<h1>Main Heading</h1>
<nav>
<a href="/home">Home</a>
<a href="/about">About</a>
</nav>
</header>
<main>
<article>
<h2>Article Title</h2>
<p>This is the first paragraph with some content.</p>
<p>This is the second paragraph with <strong>bold text</strong> and <em>italic text</em>.</p>
<img src="image1.jpg" alt="Sample image">
<img src="image2.jpg" alt="">
<img src="image3.jpg">
</article>
<aside>
<h3>Sidebar</h3>
<p>Sidebar content here.</p>
</aside>
</main>
<footer>
<p>Footer content</p>
<a href="https://external.com">External Link</a>
</footer>
</body>
</html>"#;
fn create_test_metadata() -> ExtractedMetadata {
let mut og_tags = HashMap::new();
og_tags.insert("title".to_string(), "OG Title".to_string());
og_tags.insert("description".to_string(), "OG Description".to_string());
let mut twitter_tags = HashMap::new();
twitter_tags.insert("card".to_string(), "summary".to_string());
ExtractedMetadata {
title: "Sample Page Title".to_string(),
meta_description: Some("This is a sample page description".to_string()),
language: Some("en".to_string()),
canonical_url: Some("https://example.com/page".to_string()),
og_tags,
twitter_tags,
favicons: vec!["/favicon.ico".to_string()],
viewport: Some("width=device-width, initial-scale=1".to_string()),
charset: Some("utf-8".to_string()),
robots: None,
author: None,
publish_date: None,
meta_keywords: None,
feed_urls: Vec::new(),
structured_data: Default::default(),
}
}
#[test]
fn test_default_extractor_creation() {
let _extractor = DefaultExtractor::new();
assert!(true); }
#[test]
fn test_create_extractor_function() {
let _extractor = create_extractor();
assert!(true); }
#[test]
fn test_extract_content_from_sample_html() {
let extractor = DefaultExtractor::new();
let parser = crate::parser::HtmlParser::new();
let dom = parser.parse(SAMPLE_HTML).unwrap();
let metadata = create_test_metadata();
let result = extractor.extract_content(&dom, "https://example.com/test", &metadata);
assert!(result.is_ok());
let processed_doc = result.unwrap();
assert!(!processed_doc.cleaned_text.is_empty());
assert!(!processed_doc.content_chunks.is_empty());
assert!(processed_doc.estimated_reading_time > 0.0);
assert_eq!(processed_doc.language_detected, Some("en".to_string()));
assert!(processed_doc
.cleaned_text
.contains("This is the first paragraph"));
assert!(processed_doc
.cleaned_text
.contains("This is the second paragraph"));
}
#[test]
fn test_extract_content_empty_html() {
let extractor = DefaultExtractor::new();
let parser = crate::parser::HtmlParser::new();
let empty_html = "<html><body></body></html>";
let dom = parser.parse(empty_html).unwrap();
let metadata = ExtractedMetadata::default();
let result = extractor.extract_content(&dom, "https://example.com/empty", &metadata);
assert!(result.is_ok());
let processed_doc = result.unwrap();
assert!(
processed_doc.cleaned_text.is_empty() || processed_doc.cleaned_text.trim().is_empty()
);
assert!(processed_doc.content_chunks.is_empty());
assert_eq!(processed_doc.estimated_reading_time, 0.0);
}
#[test]
fn test_extract_content_with_various_elements() {
let html_with_elements = r#"
<html>
<body>
<main>
<h1>Title</h1>
<p>First paragraph.</p>
<ul>
<li>List item 1</li>
<li>List item 2</li>
</ul>
<blockquote>This is a quote.</blockquote>
<table>
<tr><td>Cell 1</td><td>Cell 2</td></tr>
</table>
</main>
</body>
</html>"#;
let extractor = DefaultExtractor::new();
let parser = crate::parser::HtmlParser::new();
let dom = parser.parse(html_with_elements).unwrap();
let metadata = ExtractedMetadata::default();
let result = extractor.extract_content(&dom, "https://example.com/elements", &metadata);
assert!(result.is_ok());
let processed_doc = result.unwrap();
println!("Cleaned text: '{}'", processed_doc.cleaned_text);
assert!(processed_doc.cleaned_text.contains("Title"));
assert!(processed_doc.cleaned_text.contains("List item 1"));
assert!(processed_doc.cleaned_text.contains("This is a quote"));
assert!(processed_doc.cleaned_text.contains("Cell 1"));
}
#[test]
fn test_extract_content_reading_time_calculation() {
let long_text = vec![
"This is a comprehensive article about web development.",
"It contains multiple paragraphs with meaningful content.",
&"Word ".repeat(50), "The article discusses various topics in detail.",
&"Content ".repeat(50),
"Including best practices and recommendations.",
&"Information ".repeat(50),
"With examples and explanations throughout.",
&"Text ".repeat(50),
]
.join(" ");
let html_with_long_text = format!(
r#"<html><body>
<article>
<h1>Long Article</h1>
<div class="content">
<p>{}</p>
</div>
</article>
</body></html>"#,
long_text
);
let extractor = DefaultExtractor::new();
let parser = crate::parser::HtmlParser::new();
let dom = parser.parse(&html_with_long_text).unwrap();
let metadata = ExtractedMetadata::default();
let result = extractor.extract_content(&dom, "https://example.com/long", &metadata);
assert!(result.is_ok());
let processed_doc = result.unwrap();
println!(
"Word count extracted: {}",
processed_doc.cleaned_text.split_whitespace().count()
);
println!(
"Estimated reading time: {} minutes",
processed_doc.estimated_reading_time
);
if processed_doc.cleaned_text.split_whitespace().count() > 100 {
assert!(processed_doc.estimated_reading_time >= 0.3);
}
assert!(processed_doc.estimated_reading_time <= 5.0); }
#[test]
fn test_extract_content_chunks() {
let html_with_sections = r#"
<html>
<body>
<h1>Main Title</h1>
<section>
<h2>Section 1</h2>
<p>Content of section 1.</p>
</section>
<section>
<h2>Section 2</h2>
<p>Content of section 2.</p>
</section>
<article>
<h2>Article Title</h2>
<p>Article content here.</p>
</article>
</body>
</html>"#;
let extractor = DefaultExtractor::new();
let parser = crate::parser::HtmlParser::new();
let dom = parser.parse(html_with_sections).unwrap();
let metadata = ExtractedMetadata::default();
let result = extractor.extract_content(&dom, "https://example.com/sections", &metadata);
assert!(result.is_ok());
let processed_doc = result.unwrap();
assert!(!processed_doc.content_chunks.is_empty());
assert!(processed_doc.cleaned_text.contains("Section 1"));
assert!(processed_doc.cleaned_text.contains("Section 2"));
assert!(processed_doc.cleaned_text.contains("Article Title"));
}
#[test]
fn test_extract_content_language_detection() {
let spanish_html = r#"
<html lang="es">
<body>
<p>Hola mundo. Este es un texto en espaƱol con muchas palabras para detectar el idioma correctamente.</p>
</body>
</html>"#;
let extractor = DefaultExtractor::new();
let parser = crate::parser::HtmlParser::new();
let dom = parser.parse(spanish_html).unwrap();
let mut metadata = ExtractedMetadata::default();
metadata.language = Some("es".to_string());
let result = extractor.extract_content(&dom, "https://example.com/spanish", &metadata);
assert!(result.is_ok());
let processed_doc = result.unwrap();
assert_eq!(processed_doc.language_detected, Some("es".to_string()));
}
#[test]
fn test_extract_content_with_script_and_style() {
let html_with_script_style = r#"
<html>
<head>
<style>body { color: red; }</style>
<script>console.log('test');</script>
</head>
<body>
<p>Visible content</p>
<script>alert('more script');</script>
<style>.hidden { display: none; }</style>
</body>
</html>"#;
let extractor = DefaultExtractor::new();
let parser = crate::parser::HtmlParser::new();
let dom = parser.parse(html_with_script_style).unwrap();
let metadata = ExtractedMetadata::default();
let result = extractor.extract_content(&dom, "https://example.com/script", &metadata);
assert!(result.is_ok());
let processed_doc = result.unwrap();
assert!(!processed_doc.cleaned_text.contains("console.log"));
assert!(!processed_doc.cleaned_text.contains("color: red"));
}
#[test]
fn test_extract_content_with_navigation_and_footer() {
let html_with_nav_footer = r#"
<html>
<body>
<nav>
<a href="/home">Home</a>
<a href="/about">About</a>
</nav>
<main>
<h1>Main Content</h1>
<p>This is the main article content that should be extracted.</p>
</main>
<footer>
<p>Copyright 2024</p>
<p>Contact info</p>
</footer>
</body>
</html>"#;
let extractor = DefaultExtractor::new();
let parser = crate::parser::HtmlParser::new();
let dom = parser.parse(html_with_nav_footer).unwrap();
let metadata = ExtractedMetadata::default();
let result = extractor.extract_content(&dom, "https://example.com/nav", &metadata);
assert!(result.is_ok());
let processed_doc = result.unwrap();
if !processed_doc.cleaned_text.is_empty() {
assert!(
processed_doc.cleaned_text.contains("Main Content")
|| processed_doc.cleaned_text.len() > 10
);
}
assert!(!processed_doc.cleaned_text.is_empty());
}
#[test]
fn test_extractor_trait_implementation() {
let extractor = DefaultExtractor::new();
let parser = crate::parser::HtmlParser::new();
let dom = parser.parse(SAMPLE_HTML).unwrap();
let metadata = create_test_metadata();
let result: Result<ProcessedDocument> =
extractor.extract_content(&dom, "https://example.com/trait", &metadata);
assert!(result.is_ok());
}
}