use crate::models::models::AnalyzeError;
use tl::{Node, VDom};
#[cfg(feature = "readability")]
use readability::extractor::extract;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExtractionStrategy {
Readability,
Heuristic,
NegativeFiltering,
Multi,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ContentScore {
pub total_score: f32,
pub text_density: f32,
pub paragraph_density: f32,
pub link_density: f32,
pub semantic_bonus: f32,
pub text_nodes: usize,
pub paragraphs: usize,
pub character_count: usize,
}
impl ContentScore {
pub fn new() -> Self {
Self {
total_score: 0.0,
text_density: 0.0,
paragraph_density: 0.0,
link_density: 0.0,
semantic_bonus: 0.0,
text_nodes: 0,
paragraphs: 0,
character_count: 0,
}
}
pub fn calculate_total(&mut self) {
let base_score = (self.text_density * 0.4 + self.paragraph_density * 0.3).min(1.0);
let link_penalty = (1.0 - self.link_density).max(0.0);
self.total_score = (base_score * link_penalty + self.semantic_bonus).min(1.0);
}
}
impl Default for ContentScore {
fn default() -> Self {
Self::new()
}
}
pub trait ContentExtractor: Send + Sync {
fn extract_content(&self, dom: &VDom) -> Result<tl::NodeHandle, AnalyzeError>;
fn extract_with_score(
&self,
dom: &VDom,
) -> Result<(tl::NodeHandle, ContentScore), AnalyzeError> {
let node = self.extract_content(dom)?;
let score = ContentScore::default(); Ok((node, score))
}
}
pub struct ReadabilityExtractor {
}
impl ReadabilityExtractor {
pub fn new() -> Self {
Self {}
}
fn find_matching_content_node(
&self,
dom: &VDom,
content_preview: &str,
) -> Option<tl::NodeHandle> {
let content_words: Vec<&str> = content_preview.split_whitespace().take(10).collect();
let candidates = vec![
".block--messages", ".thread-content", ".forum-thread", ".message-content", ".post-content",
".forum-post",
".comment-content",
"main",
"article",
".content",
".post",
".entry",
"#content",
"#main",
".main-content",
"body",
];
for selector in candidates {
if let Some(mut nodes) = dom.query_selector(selector) {
while let Some(node_handle) = nodes.next() {
if let Some(_node) = node_handle.get(dom.parser()) {
let node_text = self.extract_node_text(dom, node_handle);
let mut matches = 0;
for word in &content_words {
if node_text.contains(word) {
matches += 1;
}
}
if matches > content_words.len() / 2 {
return Some(node_handle);
}
}
}
}
}
let mut best_node = None;
let mut best_text_length = 0;
if let Some(mut body_nodes) = dom.query_selector("body") {
if let Some(body) = body_nodes.next() {
self.find_best_content_node_recursive(
dom,
body,
&mut best_node,
&mut best_text_length,
);
}
}
best_node
}
fn find_best_content_node_recursive(
&self,
dom: &VDom,
root_handle: tl::NodeHandle,
best_node: &mut Option<tl::NodeHandle>,
best_length: &mut usize,
) {
let mut stack = vec![root_handle];
while let Some(node_handle) = stack.pop() {
if let Some(node) = node_handle.get(dom.parser()) {
if let Some(tag) = node.as_tag() {
let tag_name = tag.name().as_utf8_str().to_lowercase();
if matches!(
tag_name.as_str(),
"nav" | "header" | "footer" | "aside" | "script" | "style"
) {
continue;
}
let text = self.extract_node_text(dom, node_handle);
let text_length = text.trim().len();
if text_length > *best_length && text_length > 200 {
*best_length = text_length;
*best_node = Some(node_handle);
}
for child in tag.children().top().iter() {
stack.push(*child);
}
}
}
}
}
fn extract_node_text(&self, dom: &VDom, node_handle: tl::NodeHandle) -> String {
use crate::content::{clean_text, is_navigation_content};
let mut text = String::new();
self.collect_text_recursive(dom, node_handle, &mut text);
let cleaned = clean_text(&text);
if is_navigation_content(&cleaned) {
return String::new();
}
cleaned
}
fn collect_text_recursive(&self, dom: &VDom, root_handle: tl::NodeHandle, text: &mut String) {
let mut stack = vec![root_handle];
while let Some(node_handle) = stack.pop() {
if let Some(node) = node_handle.get(dom.parser()) {
match node {
tl::Node::Raw(raw) => {
let raw_text = raw.as_utf8_str();
if raw_text.trim().len() > 2 {
text.push_str(&raw_text);
text.push(' ');
}
}
tl::Node::Tag(tag) => {
let tag_name = tag.name().as_utf8_str().to_lowercase();
if matches!(
tag_name.as_str(),
"script"
| "style"
| "noscript"
| "iframe"
| "embed"
| "object"
| "button"
| "input"
| "select"
| "textarea"
| "form"
| "nav"
| "header"
| "footer"
| "aside"
) {
continue;
}
if let Some(Some(class)) = tag.attributes().get("class") {
let class_str = class.as_utf8_str().to_lowercase();
if class_str.contains("nav")
|| class_str.contains("menu")
|| class_str.contains("sidebar")
|| class_str.contains("breadcrumb")
|| class_str.contains("toolbar")
|| class_str.contains("footer")
|| class_str.contains("header")
{
continue;
}
}
if let Some(Some(id)) = tag.attributes().get("id") {
let id_str = id.as_utf8_str().to_lowercase();
if id_str.contains("nav")
|| id_str.contains("menu")
|| id_str.contains("sidebar")
|| id_str.contains("breadcrumb")
|| id_str.contains("toolbar")
|| id_str.contains("footer")
|| id_str.contains("header")
{
continue;
}
}
for child in tag.children().top().iter() {
stack.push(*child);
}
}
_ => {}
}
}
}
}
}
impl ContentExtractor for ReadabilityExtractor {
fn extract_content(&self, dom: &VDom) -> Result<tl::NodeHandle, AnalyzeError> {
#[cfg(feature = "readability")]
{
let html = dom.outer_html();
match extract(
&mut html.as_bytes(),
&url::Url::parse("http://example.com").unwrap(),
) {
Ok(product) => {
eprintln!(
"🔍 Readability extracted {} chars: {}",
product.content.len(),
&product.content[..product.content.len().min(100)]
);
eprintln!(
"✅ Using readability-guided heuristic extraction for better accuracy"
);
let content_preview = &product.content[..product.content.len().min(500)];
if let Some(matching_node) =
self.find_matching_content_node(dom, content_preview)
{
Ok(matching_node)
} else {
eprintln!("⚠️ Readability content not found in original DOM, using heuristic fallback");
let heuristic = HeuristicExtractor::new();
heuristic.extract_content(dom)
}
}
Err(e) => {
eprintln!("❌ Readability library failed: {:?}", e);
let heuristic = HeuristicExtractor::new();
heuristic.extract_content(dom)
}
}
}
#[cfg(not(feature = "readability"))]
{
let heuristic = HeuristicExtractor::new();
heuristic.extract_content(dom)
}
}
}
pub struct HeuristicExtractor {
}
impl HeuristicExtractor {
pub fn new() -> Self {
Self {}
}
fn score_node(&self, dom: &VDom, node_handle: tl::NodeHandle) -> ContentScore {
let mut score = ContentScore::new();
if let Some(_node) = node_handle.get(dom.parser()) {
let text_content = self.extract_text_content(dom, node_handle);
score.character_count = text_content.len();
let (elements, text_nodes, paragraphs) = self.count_elements(dom, node_handle);
score.text_nodes = text_nodes;
score.paragraphs = paragraphs;
if elements > 0 {
score.text_density = score.character_count as f32 / elements as f32;
score.paragraph_density = paragraphs as f32 / elements as f32;
}
score.link_density = self.calculate_link_density(dom, node_handle);
score.semantic_bonus = self.calculate_semantic_bonus(dom, node_handle);
score.calculate_total();
}
score
}
fn calculate_semantic_bonus(&self, dom: &VDom, node_handle: tl::NodeHandle) -> f32 {
if let Some(node) = node_handle.get(dom.parser()) {
let mut bonus: f32 = 0.0;
if let Some(tag) = node.as_tag() {
if let Some(id) = tag.attributes().get("id").flatten() {
if is_content_id(&id.as_utf8_str()) {
bonus += 0.3;
}
}
if let Some(class) = tag.attributes().get("class").flatten() {
if is_content_class(&class.as_utf8_str()) {
bonus += 0.2;
}
}
}
bonus.min(1.0)
} else {
0.0
}
}
fn calculate_link_density(&self, dom: &VDom, node_handle: tl::NodeHandle) -> f32 {
let total_text = self.extract_text_content(dom, node_handle).len();
if total_text == 0 {
return 0.0;
}
let mut link_text_length = 0;
self.traverse_links(dom, node_handle, &mut |link_text| {
link_text_length += link_text.len();
});
link_text_length as f32 / total_text as f32
}
fn count_elements(&self, dom: &VDom, node_handle: tl::NodeHandle) -> (usize, usize, usize) {
let mut elements = 0;
let mut text_nodes = 0;
let mut paragraphs = 0;
self.traverse_nodes(dom, node_handle, &mut |node| {
match node {
Node::Tag(tag) => {
elements += 1;
if tag.name().as_utf8_str().eq_ignore_ascii_case("p") {
paragraphs += 1;
}
}
Node::Raw(_) => {
text_nodes += 1;
}
Node::Comment(_) => {
}
}
});
(elements, text_nodes, paragraphs)
}
fn extract_text_content(&self, dom: &VDom, node_handle: tl::NodeHandle) -> String {
use crate::content::{clean_text, is_navigation_content};
let mut text = String::new();
self.traverse_nodes(dom, node_handle, &mut |node| {
if let Node::Raw(raw) = node {
let raw_text = raw.as_utf8_str();
if raw_text.trim().len() > 2 {
text.push_str(&raw_text);
}
}
});
let cleaned = clean_text(&text);
if is_navigation_content(&cleaned) {
return String::new();
}
cleaned
}
fn traverse_nodes<F>(&self, dom: &VDom, node_handle: tl::NodeHandle, func: &mut F)
where
F: FnMut(&Node),
{
if let Some(node) = node_handle.get(dom.parser()) {
if let Some(tag) = node.as_tag() {
let tag_name = tag.name().as_utf8_str().to_lowercase();
const EXCLUDED_TAGS: &[&str] = &[
"script", "style", "noscript", "iframe", "object", "embed", "svg", "canvas",
"code", "pre", ];
if EXCLUDED_TAGS.contains(&tag_name.as_str()) {
return;
}
}
func(node);
if let Some(tag) = node.as_tag() {
for child in tag.children().top().iter() {
self.traverse_nodes(dom, *child, func);
}
}
}
}
fn traverse_links<F>(&self, dom: &VDom, node_handle: tl::NodeHandle, func: &mut F)
where
F: FnMut(&str),
{
if let Some(node) = node_handle.get(dom.parser()) {
if let Some(tag) = node.as_tag() {
let tag_name = tag.name().as_utf8_str().to_lowercase();
const EXCLUDED_TAGS: &[&str] = &[
"script", "style", "noscript", "iframe", "object", "embed", "svg", "canvas",
];
if EXCLUDED_TAGS.contains(&tag_name.as_str()) {
return;
}
if tag.name().as_utf8_str().eq_ignore_ascii_case("a") {
let link_text = self.extract_text_content(dom, node_handle);
func(&link_text);
} else {
for child in tag.children().top().iter() {
self.traverse_links(dom, *child, func);
}
}
}
}
}
}
impl ContentExtractor for HeuristicExtractor {
fn extract_content(&self, dom: &VDom) -> Result<tl::NodeHandle, AnalyzeError> {
let mut best_node: Option<tl::NodeHandle> = None;
let mut best_score = ContentScore::new();
let wikipedia_priority_selectors = ["#mp-upper"];
for selector in wikipedia_priority_selectors {
if let Some(node) = dom
.query_selector(selector)
.and_then(|mut iter| iter.next())
{
let score = self.score_node(dom, node);
if score.total_score > 0.5 {
return Ok(node);
}
}
}
let candidates = vec![
".block--messages", ".thread-content",
".forum-thread",
".message-content",
".post-content",
".forum-post",
".comment-content",
"[class*='message']",
"#mp-left",
"#mp-right",
"#mw-content-text",
".mw-parser-output",
"article",
"[role='main']",
".entry-content",
".article-content",
"#main-content",
".page-content",
".content-area",
".content",
".post",
".entry",
"main",
"#content",
"#contents",
".mw-body",
"#bodyContent",
];
for selector in candidates {
if let Some(node) = dom
.query_selector(selector)
.and_then(|mut iter| iter.next())
{
let score = self.score_node(dom, node);
if score.total_score > best_score.total_score {
best_score = score;
best_node = Some(node);
}
}
}
if let Some(node) = best_node {
if best_score.total_score > 0.0 {
return Ok(node);
}
}
if let Some(body) = dom.query_selector("body").and_then(|mut iter| iter.next()) {
Ok(body)
} else {
Err(AnalyzeError::ParseError(
"No suitable content node found".to_string(),
))
}
}
fn extract_with_score(
&self,
dom: &VDom,
) -> Result<(tl::NodeHandle, ContentScore), AnalyzeError> {
let node = self.extract_content(dom)?;
let score = self.score_node(dom, node);
Ok((node, score))
}
}
pub struct NegativeFilteringExtractor {
}
impl NegativeFilteringExtractor {
pub fn new() -> Self {
Self {}
}
}
impl ContentExtractor for NegativeFilteringExtractor {
fn extract_content(&self, dom: &VDom) -> Result<tl::NodeHandle, AnalyzeError> {
if let Some(body) = dom.query_selector("body").and_then(|mut iter| iter.next()) {
Ok(body)
} else if let Some(first_child) = dom.children().iter().next().copied() {
Ok(first_child)
} else {
if let Some(html) = dom.query_selector("html").and_then(|mut iter| iter.next()) {
Ok(html)
} else {
Err(AnalyzeError::ParseError(
"No content found in document".to_string(),
))
}
}
}
}
pub struct MultiStrategyExtractor {
extractors: Vec<Box<dyn ContentExtractor>>,
}
impl MultiStrategyExtractor {
pub fn new() -> Self {
Self {
extractors: Vec::new(),
}
}
pub fn add_extractor(&mut self, extractor: Box<dyn ContentExtractor>) {
self.extractors.push(extractor);
}
pub fn extract_with_fallback(&self, dom: &VDom) -> Result<tl::NodeHandle, AnalyzeError> {
let mut last_error = None;
for extractor in &self.extractors {
match extractor.extract_content(dom) {
Ok(node) => return Ok(node),
Err(e) => last_error = Some(e),
}
}
Err(last_error.unwrap_or_else(|| {
AnalyzeError::ParseError("All content extraction strategies failed".to_string())
}))
}
}
impl ContentExtractor for MultiStrategyExtractor {
fn extract_content(&self, dom: &VDom) -> Result<tl::NodeHandle, AnalyzeError> {
self.extract_with_fallback(dom)
}
}
fn is_content_id(id: &str) -> bool {
let content_patterns = [
"content",
"main",
"article",
"post",
"entry",
"body-content",
"page-content",
"main-content",
"primary",
"wrapper",
"message",
"comment",
"thread",
"discussion",
];
let id_lower = id.to_lowercase();
content_patterns
.iter()
.any(|pattern| id_lower.contains(pattern))
}
fn is_content_class(class: &str) -> bool {
let content_patterns = [
"content",
"main",
"article",
"post",
"entry",
"body",
"page",
"primary",
"container",
"wrapper",
"message",
"comment",
"thread",
"discussion",
"forum",
];
let class_lower = class.to_lowercase();
content_patterns
.iter()
.any(|pattern| class_lower.contains(pattern))
}
pub fn create_content_extractor() -> MultiStrategyExtractor {
MultiStrategyExtractor::new()
}
pub fn create_heuristic_extractor() -> Box<dyn ContentExtractor> {
Box::new(HeuristicExtractor::new())
}
pub fn create_readability_extractor() -> Box<dyn ContentExtractor> {
Box::new(ReadabilityExtractor::new())
}
pub fn create_negative_filtering_extractor() -> Box<dyn ContentExtractor> {
Box::new(NegativeFilteringExtractor::new())
}
pub fn create_multi_strategy_extractor() -> MultiStrategyExtractor {
let mut extractor = MultiStrategyExtractor::new();
extractor.add_extractor(Box::new(ReadabilityExtractor::new()));
extractor.add_extractor(Box::new(HeuristicExtractor::new()));
extractor.add_extractor(Box::new(NegativeFilteringExtractor::new()));
extractor
}
#[cfg(test)]
mod tests {
use super::*;
use tl::ParserOptions;
fn create_test_dom(html: &str) -> VDom {
tl::parse(html, ParserOptions::default()).unwrap()
}
#[test]
fn test_content_score_calculation() {
let mut score = ContentScore::new();
score.text_density = 0.8;
score.paragraph_density = 0.6;
score.link_density = 0.2;
score.semantic_bonus = 0.1;
score.calculate_total();
assert!(score.total_score > 0.0);
assert!(score.total_score <= 1.0);
}
#[test]
fn test_heuristic_extractor() {
let html = r#"
<html>
<body>
<nav>Navigation</nav>
<main id="content">
<article>
<h1>Main Article</h1>
<p>This is the main content of the article.</p>
<p>Another paragraph with more content.</p>
</article>
</main>
<footer>Footer content</footer>
</body>
</html>
"#;
let dom = create_test_dom(html);
let extractor = HeuristicExtractor::new();
let result = extractor.extract_content(&dom);
assert!(result.is_ok());
}
#[test]
fn test_multi_strategy_extractor() {
let html = r#"
<html>
<body>
<div class="content">
<p>Main content here</p>
</div>
</body>
</html>
"#;
let dom = create_test_dom(html);
let mut extractor = MultiStrategyExtractor::new();
extractor.add_extractor(Box::new(HeuristicExtractor::new()));
let result = extractor.extract_content(&dom);
assert!(result.is_ok());
}
#[test]
fn test_content_patterns() {
assert!(is_content_id("main-content"));
assert!(is_content_id("article-body"));
assert!(!is_content_id("sidebar"));
assert!(is_content_class("content main"));
assert!(is_content_class("post-content"));
assert!(!is_content_class("navigation"));
}
}