#![allow(clippy::cast_precision_loss)]
use crate::constants::{CONTENT_INDICATORS, NAVIGATION_INDICATORS, NON_CONTENT_PATTERNS};
use once_cell::sync::Lazy;
use regex::Regex;
use tracing::{debug, instrument};
static DATE_PATTERN: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{1,2},?\s+\d{4}\b")
.expect("Invalid regex")
});
static AUTHOR_PATTERN: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"\b(?:by|written by|author:)\s+[A-Za-z\s]+\b").expect("Invalid regex")
});
static PARAGRAPH_PATTERN: Lazy<Regex> =
Lazy::new(|| Regex::new(r"<p[^>]*>.*?</p>").expect("Invalid regex"));
static LINK_PATTERN: Lazy<Regex> =
Lazy::new(|| Regex::new(r"<a[^>]*>.*?</a>").expect("Invalid regex"));
static IMAGE_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"<img[^>]*>").expect("Invalid regex"));
#[derive(Debug, Clone)]
pub struct ContentScore {
pub score: f64,
pub element_id: String,
}
pub struct ContentScorer;
impl ContentScorer {
#[instrument(skip(text))]
pub fn score_text(text: &str) -> f32 {
let mut score = 0.0;
let word_count = text.split_whitespace().count();
let word_count_f32 = word_count as f32;
score += word_count_f32;
let paragraphs = PARAGRAPH_PATTERN.find_iter(text).count();
let paragraphs_f32 = paragraphs as f32;
if paragraphs > 0 {
score += paragraphs_f32 * 5.0;
}
let links = LINK_PATTERN.find_iter(text).count();
let links_f32 = links as f32;
if word_count > 0 {
let link_density = links_f32 / word_count_f32;
if link_density > 0.5 {
score *= 0.5;
}
}
let images = IMAGE_PATTERN.find_iter(text).count();
let images_f32 = images as f32;
score += images_f32 * 3.0;
for indicator in CONTENT_INDICATORS {
if text.contains(indicator) {
score += 10.0;
}
}
for indicator in NAVIGATION_INDICATORS {
if text.contains(indicator) {
score -= 20.0;
}
}
for pattern in NON_CONTENT_PATTERNS {
if text.contains(pattern) {
score -= 30.0;
}
}
if DATE_PATTERN.is_match(text) {
score += 5.0;
}
if AUTHOR_PATTERN.is_match(text) {
score += 5.0;
}
debug!("Scored content with {} words: {}", word_count, score);
score.max(0.0)
}
pub fn score_by_attributes(tag: &str, class: Option<&str>, id: Option<&str>) -> f32 {
let mut score = 0.0;
match tag {
"article" | "main" => score += 20.0,
"section" => score += 10.0,
"div" => score += 5.0,
"nav" | "aside" | "footer" | "header" => score -= 20.0,
_ => {}
}
if let Some(class_str) = class {
let class_lower = class_str.to_lowercase();
if class_lower.contains("content")
|| class_lower.contains("article")
|| class_lower.contains("post")
|| class_lower.contains("entry")
{
score += 15.0;
}
if class_lower.contains("nav")
|| class_lower.contains("menu")
|| class_lower.contains("sidebar")
|| class_lower.contains("comment")
{
score -= 15.0;
}
}
if let Some(id_str) = id {
let id_lower = id_str.to_lowercase();
if id_lower.contains("content") || id_lower.contains("main") {
score += 10.0;
}
if id_lower.contains("nav") || id_lower.contains("sidebar") {
score -= 10.0;
}
}
score
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_score_text() {
let text = r"
<p>This is a paragraph with some content.</p>
<p>Another paragraph with more text.</p>
";
let score = ContentScorer::score_text(text);
assert!(score > 0.0);
}
#[test]
fn test_score_by_attributes() {
let score = ContentScorer::score_by_attributes("article", Some("post-content"), None);
assert!(score > 20.0);
let nav_score = ContentScorer::score_by_attributes("nav", None, None);
assert!(nav_score < 0.0);
}
}