use anyhow::{Context, Result};
use dom_content_extraction::{DensityTree, scraper::Html};
use rayon::prelude::*;
use regex::Regex;
use std::{
fs,
path::Path,
time::{Duration, Instant},
};
use strsim::sorensen_dice;
fn normalize_text(text: &str) -> String {
text.split_whitespace().collect::<Vec<&str>>().join(" ")
}
fn clean_and_normalize_text(text: &str) -> String {
let punctuation_regex = Regex::new(r"[^\w\s']").unwrap();
let text = punctuation_regex.replace_all(text, " ");
let space_regex = Regex::new(r"\s+").unwrap();
let text = space_regex.replace_all(&text, " ");
let text = text.to_lowercase();
text.trim().to_string()
}
fn extract_content_from_html(file_path: &Path) -> Result<String> {
let content = fs::read(file_path)
.with_context(|| format!("Failed to read file: {:?}", file_path))?;
let content = String::from_utf8_lossy(&content).into_owned();
let document = Html::parse_document(&content);
let mut dtree = DensityTree::from_document(&document).unwrap();
let _ = dtree.calculate_density_sum();
let extracted_content = dtree.extract_content(&document).unwrap();
Ok(normalize_text(&extracted_content))
}
fn clean_txt_file(file_path: &Path) -> Result<String> {
let content = fs::read(file_path)
.with_context(|| format!("Failed to read file: {:?}", file_path))?;
let content = String::from_utf8_lossy(&content).into_owned();
let content = content.lines().skip(1).collect::<Vec<&str>>().join("\n");
let re = Regex::new(r"<[hl/p]+>")?;
let content = re.replace_all(&content, "");
let content = content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<&str>>()
.join(" ");
Ok(content)
}
fn calculate_lcs(s1: &str, s2: &str) -> usize {
let s1: Vec<&str> = s1.split_whitespace().collect();
let s2: Vec<&str> = s2.split_whitespace().collect();
let (m, n) = (s1.len(), s2.len());
let mut prev = vec![0; n + 1];
let mut curr = vec![0; n + 1];
for i in 1..=m {
for j in 1..=n {
if s1[i - 1] == s2[j - 1] {
curr[j] = prev[j - 1] + 1;
} else {
curr[j] = curr[j - 1].max(prev[j]);
}
}
std::mem::swap(&mut prev, &mut curr);
}
let lcs_words = prev[n];
if lcs_words == 0 {
return 0;
}
let avg_word_len1 = if s1.is_empty() {
0.0
} else {
s1.iter().map(|w| w.len()).sum::<usize>() as f64 / s1.len() as f64
};
let avg_word_len2 = if s2.is_empty() {
0.0
} else {
s2.iter().map(|w| w.len()).sum::<usize>() as f64 / s2.len() as f64
};
let avg_word_len = (avg_word_len1 + avg_word_len2) / 2.0;
(lcs_words as f64 * (avg_word_len + 1.0)) as usize
}
struct ScoringResult {
precision: f64,
recall: f64,
f1: f64,
dice: f64,
duration: Duration,
}
fn process_file_pair(txt_path: &Path, html_path: &Path) -> Result<ScoringResult> {
let file_start = Instant::now();
let clean_content = clean_txt_file(txt_path)?;
let clean_content = clean_and_normalize_text(&clean_content);
let extracted_content = extract_content_from_html(html_path)?;
let extracted_content = clean_and_normalize_text(&extracted_content);
let lcs_length = calculate_lcs(&clean_content, &extracted_content);
let precision = lcs_length as f64 / extracted_content.len() as f64;
let recall = lcs_length as f64 / clean_content.len() as f64;
let f1 = 2.0 * (precision * recall) / (precision + recall);
let dice = sorensen_dice(&extracted_content, &clean_content);
Ok(ScoringResult {
precision,
recall,
f1,
dice,
duration: file_start.elapsed(),
})
}
fn main() -> Result<()> {
let gold_standard_dir = Path::new("data/GoldStandard");
let html_input_dir = Path::new("data/finalrun-input");
let start_time = Instant::now();
let entries: Vec<_> =
fs::read_dir(gold_standard_dir)?.collect::<std::io::Result<Vec<_>>>()?;
let results: Vec<_> = entries
.par_iter()
.filter_map(|entry| {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("txt") {
let file_name = path.file_stem().unwrap().to_str().unwrap();
let html_path = html_input_dir.join(format!("{}.html", file_name));
if html_path.exists() {
match process_file_pair(&path, &html_path) {
Ok(result)
if !result.precision.is_nan()
&& !result.recall.is_nan()
&& !result.f1.is_nan() =>
{
println!("File: {}", file_name);
println!(" Precision: {:.2}", result.precision);
println!(" Recall: {:.2}", result.recall);
println!(" F1 Score: {:.2}", result.f1);
println!(" Sorensen-Dice: {:.2}", result.dice);
println!(" Processing time: {:.2?}", result.duration);
if result.duration > Duration::from_millis(500) {
println!(" ⚠️ SLOW PROCESSING");
}
println!();
Some(result)
}
Ok(_) => {
println!(
"File: {} produced NaN results (skipped)",
file_name
);
None
}
Err(e) => {
println!(
"Error processing file {}: {:?}",
file_name, e
);
None
}
}
} else {
None
}
} else {
None
}
})
.collect();
let total_results = results.len();
if total_results == 0 {
println!("No valid results found.");
return Ok(());
}
let (total_precision, total_recall, total_f1, total_dice) =
results.iter().fold((0.0, 0.0, 0.0, 0.0), |acc, r| {
(
acc.0 + r.precision,
acc.1 + r.recall,
acc.2 + r.f1,
acc.3 + r.dice,
)
});
let n = total_results as f64;
println!("Overall Performance:");
println!(" Files processed: {}", total_results);
println!(" Average Precision: {:.2}", total_precision / n);
println!(" Average Recall: {:.2}", total_recall / n);
println!(" Average F1 Score: {:.2}", total_f1 / n);
println!(" Average Sorensen-Dice: {:.2}", total_dice / n);
let total_duration = start_time.elapsed();
println!("Total processing time: {:.2?}", total_duration);
println!(
"Average time per file: {:.2?}",
total_duration / total_results as u32
);
Ok(())
}