use crate::constants::REGEXPS;
use scraper::{Html, Selector};
use std::sync::LazyLock;
pub(super) fn extract_title_from_document(document: &Html) -> Option<String> {
let title_selector = Selector::parse("title").unwrap();
let title_elem = document.select(&title_selector).next()?;
let orig_title = title_elem.text().collect::<String>().trim().to_string();
if orig_title.is_empty() {
return None;
}
let mut cur_title = orig_title.clone();
let mut title_had_hierarchical_separators = false;
fn word_count(s: &str) -> usize {
s.split_whitespace().count()
}
static TITLE_SEPARATOR_REGEX: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\s(\||\-|–|—|\\|/|>|»)\s").unwrap());
static HIERARCHICAL_SEPARATOR_REGEX: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\s[\\//>»]\s").unwrap());
static LEADING_SEGMENT_REGEX: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(?i)^[^\|\-–—\\//>»]*[\|\-–—\\//>»]").unwrap());
let sep_regex = &*TITLE_SEPARATOR_REGEX;
if sep_regex.is_match(&cur_title) {
title_had_hierarchical_separators = HIERARCHICAL_SEPARATOR_REGEX.is_match(&cur_title);
let sep_matches: Vec<_> = sep_regex.find_iter(&orig_title).collect();
if let Some(last_sep) = sep_matches.last() {
cur_title = orig_title[..last_sep.start()].to_string();
if word_count(&cur_title) < 3 {
cur_title = LEADING_SEGMENT_REGEX.replace(&orig_title, "").to_string();
}
}
} else if cur_title.contains(": ") {
let h_selector = Selector::parse("h1, h2").unwrap();
let trimmed_title = cur_title.trim();
let has_matching_heading = document
.select(&h_selector)
.any(|h| h.text().collect::<String>().trim() == trimmed_title);
if !has_matching_heading {
if let Some(last_colon_pos) = cur_title.rfind(':') {
let after_colon = cur_title[(last_colon_pos + 1)..].trim().to_string();
if word_count(&after_colon) < 3 {
if let Some(first_colon_pos) = cur_title.find(':') {
let after_first = cur_title[(first_colon_pos + 1)..].trim().to_string();
let before_first = &cur_title[..first_colon_pos];
if word_count(before_first) > 5 {
cur_title = orig_title.clone();
} else {
cur_title = after_first;
}
}
} else {
cur_title = after_colon;
}
}
}
} else if cur_title.len() > 150 || cur_title.len() < 15 {
let h1_selector = Selector::parse("h1").unwrap();
let h1s: Vec<_> = document.select(&h1_selector).collect();
if h1s.len() == 1 {
cur_title = h1s[0].text().collect::<String>().trim().to_string();
}
}
cur_title = REGEXPS
.normalize
.replace_all(cur_title.trim(), " ")
.to_string();
let cur_word_count = word_count(&cur_title);
if cur_word_count <= 4 {
let orig_without_sep = sep_regex.replace_all(&orig_title, " ").to_string();
let orig_word_count = word_count(&orig_without_sep);
if !title_had_hierarchical_separators || cur_word_count != orig_word_count - 1 {
cur_title = orig_title;
}
}
Some(cur_title)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_title_extraction() {
let html = r#"
<html>
<head>
<title>Article Title | Site Name</title>
</head>
</html>
"#;
let document = Html::parse_document(html);
let title = extract_title_from_document(&document);
assert!(title.is_some());
assert!(title.as_ref().unwrap().contains("Article Title"));
}
#[test]
fn test_title_extraction_colon() {
let html = r#"
<html>
<head>
<title>Site Name: Article Title</title>
</head>
</html>
"#;
let document = Html::parse_document(html);
let title = extract_title_from_document(&document);
assert!(title.is_some());
assert!(!title.as_ref().unwrap().is_empty());
}
}