diff --git a/Cargo.lock b/Cargo.lock
index 08b91c2..f30e0fc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1736,6 +1736,7 @@ dependencies = [
"html2md",
"html5ever 0.36.1",
"regex",
+ "reqwest",
"scraper",
"sxd-document",
"sxd-xpath",
diff --git a/crates/readability/Cargo.toml b/crates/readability/Cargo.toml
index c28fc0d..0b79ff1 100644
--- a/crates/readability/Cargo.toml
+++ b/crates/readability/Cargo.toml
@@ -15,4 +15,5 @@ thiserror = "2.0"
regex = "1.12"
[dev-dependencies]
-tokio = { version = "1.48", features = ["test-util"] }
+tokio = { version = "1.48", features = ["rt-multi-thread", "macros"] }
+reqwest = "0.12"
diff --git a/crates/readability/src/cleaner/sanitizer.rs b/crates/readability/src/cleaner/sanitizer.rs
index fff2a45..3b8eae4 100644
--- a/crates/readability/src/cleaner/sanitizer.rs
+++ b/crates/readability/src/cleaner/sanitizer.rs
@@ -1,30 +1,312 @@
//! HTML sanitization and cleaning
+//!
+//! This module provides utilities to clean extracted HTML content,
+//! removing scripts, styles, unwanted elements, and normalizing the output.
+use crate::extractor::scoring::is_unlikely_candidate;
+
+use scraper::{Html, Selector};
/// HTML cleaner and sanitizer
pub struct HtmlCleaner;
impl HtmlCleaner {
- /// Clean HTML content
+ /// Clean HTML content by applying all cleaning steps
+ ///
+ /// Steps:
+ /// 1. Remove scripts and styles
+ /// 2. Remove unlikely candidates (sidebar, comments, etc.)
+ /// 3. Remove empty elements
+ /// 4. Clean attributes (keep only essential ones)
+ /// 5. Normalize whitespace
pub fn clean(html: &str) -> String {
- // TODO: Implement cleaning
- html.to_string()
+ let mut result = Self::remove_scripts_and_styles(html);
+ result = Self::remove_unlikely_elements(&result);
+ result = Self::remove_empty_elements(&result);
+ result = Self::clean_attributes(&result);
+ result = Self::normalize_whitespace(&result);
+ result
}
- /// Remove scripts and styles
+ /// Remove script and style tags and their contents
pub fn remove_scripts_and_styles(html: &str) -> String {
- // TODO: Implement
- html.to_string()
+ let document = Html::parse_fragment(html);
+ let mut result = html.to_string();
+
+ if let Ok(selector) = Selector::parse("script") {
+ for element in document.select(&selector) {
+ let element_html = element.html();
+ result = result.replace(&element_html, "");
+ }
+ }
+
+ if let Ok(selector) = Selector::parse("style") {
+ for element in document.select(&selector) {
+ let element_html = element.html();
+ result = result.replace(&element_html, "");
+ }
+ }
+
+ if let Ok(selector) = Selector::parse("noscript") {
+ for element in document.select(&selector) {
+ let element_html = element.html();
+ result = result.replace(&element_html, "");
+ }
+ }
+
+ if let Ok(selector) = Selector::parse("link[rel='stylesheet']") {
+ for element in document.select(&selector) {
+ let element_html = element.html();
+ result = result.replace(&element_html, "");
+ }
+ }
+
+ result
}
- /// Normalize whitespace
+ /// Remove elements that are unlikely to be main content
+ pub fn remove_unlikely_elements(html: &str) -> String {
+ let document = Html::parse_fragment(html);
+ let mut result = html.to_string();
+
+ let unlikely_selectors = [
+ "nav",
+ "aside",
+ "footer",
+ "header",
+ "[role='navigation']",
+ "[role='banner']",
+ "[role='contentinfo']",
+ "[role='complementary']",
+ ".sidebar",
+ ".advertisement",
+ ".ad",
+ ".ads",
+ ".social-share",
+ ".share-buttons",
+ ".related-posts",
+ ".comments",
+ "#comments",
+ ".comment-section",
+ ];
+
+ for selector_str in unlikely_selectors {
+ if let Ok(selector) = Selector::parse(selector_str) {
+ for element in document.select(&selector) {
+ let element_html = element.html();
+ result = result.replace(&element_html, "");
+ }
+ }
+ }
+
+ if let Ok(div_selector) = Selector::parse("div, section, aside, span") {
+ for element in document.select(&div_selector) {
+ if is_unlikely_candidate(element) {
+ let element_html = element.html();
+ result = result.replace(&element_html, "");
+ }
+ }
+ }
+
+ result
+ }
+
+ /// Clean attributes - keep only essential ones
+ ///
+ /// Keeps: href, src, alt, title, datetime, class (filtered)
+ /// Removes: onclick, onload, style, data-*, etc.
+ pub fn clean_attributes(html: &str) -> String {
+ use regex::Regex;
+
+ let mut result = html.to_string();
+
+ let event_attrs = [
+ "onclick",
+ "onload",
+ "onerror",
+ "onmouseover",
+ "onmouseout",
+ "onkeydown",
+ "onkeyup",
+ "onfocus",
+ "onblur",
+ "onsubmit",
+ ];
+
+ for attr in event_attrs {
+ if let Ok(regex) = Regex::new(&format!(r#"\s+{}="[^"]*""#, attr)) {
+ result = regex.replace_all(&result, "").to_string();
+ }
+ if let Ok(regex) = Regex::new(&format!(r#"\s+{}='[^']*'"#, attr)) {
+ result = regex.replace_all(&result, "").to_string();
+ }
+ }
+
+ if let Ok(regex) = Regex::new(r#"\s+style="[^"]*""#) {
+ result = regex.replace_all(&result, "").to_string();
+ }
+
+ if let Ok(regex) = Regex::new(r#"\s+data-[a-z-]+="[^"]*""#) {
+ result = regex.replace_all(&result, "").to_string();
+ }
+
+ result
+ }
+
+ /// Normalize whitespace - collapse multiple spaces/newlines
pub fn normalize_whitespace(html: &str) -> String {
- // TODO: Implement
- html.to_string()
+ use regex::Regex;
+
+ let mut result = html.to_string();
+
+ if let Ok(regex) = Regex::new(r"[ \t]+") {
+ result = regex.replace_all(&result, " ").to_string();
+ }
+
+ if let Ok(regex) = Regex::new(r"\n{3,}") {
+ result = regex.replace_all(&result, "\n\n").to_string();
+ }
+
+ if let Ok(regex) = Regex::new(r"\n{3,}") {
+ result = regex.replace_all(&result, "\n\n").to_string();
+ }
+
+ if let Ok(regex) = Regex::new(r"(?m)^[ \t]+|[ \t]+$") {
+ result = regex.replace_all(&result, "").to_string();
+ }
+
+ result.trim().to_string()
}
- /// Remove empty elements
+ /// Remove empty elements (paragraphs, divs, spans with no content)
pub fn remove_empty_elements(html: &str) -> String {
- // TODO: Implement
- html.to_string()
+ use regex::Regex;
+
+ let mut result = html.to_string();
+
+ if let Ok(regex) = Regex::new(r"
]*>\s*
") {
+ result = regex.replace_all(&result, "").to_string();
+ }
+
+ if let Ok(regex) = Regex::new(r"
]*>\s*
") {
+ result = regex.replace_all(&result, "").to_string();
+ }
+
+ if let Ok(regex) = Regex::new(r"]*>\s*") {
+ result = regex.replace_all(&result, "").to_string();
+ }
+
+ if let Ok(regex) = Regex::new(r"
]*>( |\s)*
") {
+ result = regex.replace_all(&result, "").to_string();
+ }
+
+ result
+ }
+
+ /// Remove elements by class or id containing specific patterns
+ pub fn remove_by_class_or_id(html: &str, patterns: &[&str]) -> String {
+ let document = Html::parse_fragment(html);
+ let mut result = html.to_string();
+
+ if let Ok(all_selector) = Selector::parse("*") {
+ for element in document.select(&all_selector) {
+ let class_str = element.value().attr("class").unwrap_or("");
+ let id_str = element.value().attr("id").unwrap_or("");
+ let combined = format!("{} {}", class_str, id_str).to_lowercase();
+
+ for pattern in patterns {
+ if combined.contains(&pattern.to_lowercase()) {
+ let element_html = element.html();
+ result = result.replace(&element_html, "");
+ break;
+ }
+ }
+ }
+ }
+
+ result
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_remove_scripts() {
+ let html = r#"
ContentMore content
"#;
+ let result = HtmlCleaner::remove_scripts_and_styles(html);
+ assert!(!result.contains("script"));
+ assert!(!result.contains("alert"));
+ }
+
+ #[test]
+ fn test_remove_styles() {
+ let html = r#"
ContentMore content
"#;
+ let result = HtmlCleaner::remove_scripts_and_styles(html);
+ assert!(!result.contains("style"));
+ assert!(!result.contains("color"));
+ }
+
+ #[test]
+ fn test_remove_empty_paragraphs() {
+ let html = r#"
Content
More
"#;
+ let result = HtmlCleaner::remove_empty_elements(html);
+ assert!(result.contains("Content"));
+ assert!(result.contains("More"));
+
+ let p_count = result.matches("
Link"#;
+ let result = HtmlCleaner::clean_attributes(html);
+ assert!(!result.contains("onclick"));
+ assert!(result.contains("href"));
+ }
+
+ #[test]
+ fn test_clean_data_attributes() {
+ let html = r#"
Content
"#;
+ let result = HtmlCleaner::clean_attributes(html);
+ assert!(!result.contains("data-tracking"));
+ assert!(!result.contains("data-id"));
+ }
+
+ #[test]
+ fn test_full_clean() {
+ let html = r#"
+
+
+
+
Good content here.
+
+
+
More content
+
+ "#;
+
+ let result = HtmlCleaner::clean(html);
+ assert!(!result.contains("script"));
+ assert!(!result.contains("style"));
+ assert!(!result.contains("onclick"));
+ assert!(result.contains("Good content"));
+ assert!(result.contains("More content"));
+ }
+
+ #[test]
+ fn test_remove_by_class_or_id() {
+ let html = r#"
Sidebar
Main
"#;
+ let result = HtmlCleaner::remove_by_class_or_id(html, &["sidebar"]);
+ assert!(!result.contains("Sidebar"));
+ assert!(result.contains("Main"));
}
}
diff --git a/crates/readability/src/extractor/generic.rs b/crates/readability/src/extractor/generic.rs
index a27697c..c671fea 100644
--- a/crates/readability/src/extractor/generic.rs
+++ b/crates/readability/src/extractor/generic.rs
@@ -1,48 +1,31 @@
-//! Generic content extraction with a simplified heuristic-based approach
+//! Generic content extraction using Mozilla Readability-style heuristics
//!
-//! ## Implementation Strategy
+//! ## Implementation Overview
//!
-//! This is a **simplified** content extractor, not a full Mozilla Readability implementation.
-//! It uses basic heuristics to find common patterns in HTML documents.
+//! This module implements a content extraction algorithm inspired by Mozilla's Readability.js.
+//! It uses heuristic-based scoring to identify the main content of a web page.
//!
-//! ### What This Implementation Does:
-//! - Extracts title from ``, `
`, or `og:title` meta tags
-//! - Finds body content by looking for semantic HTML5 tags and common class names
-//! - Extracts author from meta tags or common byline patterns
-//! - Extracts date from meta tags or `