use scraper::{ElementRef, Html, Selector};
use std::time::Duration;
use tracing::debug;
use crate::state::types::AurComment;
type Result<T> = super::Result<T>;
struct CommentExtractionContext<'a> {
document: &'a Html,
date_selector: &'a Selector,
pkgname: &'a str,
html_text: &'a str,
has_pinned_section: bool,
latest_comments_pos: Option<usize>,
}
pub async fn fetch_aur_comments(pkgname: String) -> Result<Vec<AurComment>> {
use reqwest::header::{ACCEPT, ACCEPT_LANGUAGE, HeaderMap, HeaderValue};
let url = format!("https://aur.archlinux.org/packages/{pkgname}");
let mut headers = HeaderMap::new();
headers.insert(
ACCEPT,
HeaderValue::from_static("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"),
);
headers.insert(ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.5"));
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.user_agent(format!(
"Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0 Pacsea/{}",
env!("CARGO_PKG_VERSION")
))
.default_headers(headers)
.build()
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
let html_text = client
.get(&url)
.send()
.await
.map_err(|e| format!("Network error: {e}"))?
.text()
.await
.map_err(|e| format!("Failed to read response: {e}"))?;
let document = Html::parse_document(&html_text);
let comment_header_selector = Selector::parse("h4.comment-header")
.map_err(|e| format!("Failed to parse comment header selector: {e}"))?;
let date_selector =
Selector::parse("a.date").map_err(|e| format!("Failed to parse date selector: {e}"))?;
let heading_selector = Selector::parse("h3, h2, h4")
.map_err(|e| format!("Failed to parse heading selector: {e}"))?;
let has_pinned_section = document.select(&heading_selector).any(|h| {
let text: String = h.text().collect();
text.contains("Pinned Comments")
});
let html_text_lower = html_text.to_lowercase();
let latest_comments_pos = html_text_lower.find("latest comments");
let all_headers: Vec<_> = document.select(&comment_header_selector).collect();
let mut seen_comment_ids = std::collections::HashSet::new();
let mut comments = Vec::new();
for (index, header) in all_headers.iter().enumerate() {
let comment_id = header.value().attr("id");
if let Some(id) = comment_id
&& !seen_comment_ids.insert(id)
{
continue; }
let context = CommentExtractionContext {
document: &document,
date_selector: &date_selector,
pkgname: &pkgname,
html_text: &html_text,
has_pinned_section,
latest_comments_pos,
};
if let Some(comment) = extract_comment_from_header(header, comment_id, index, &context) {
comments.push(comment);
}
}
Ok(separate_and_sort_comments(comments))
}
fn extract_comment_from_header(
header: &ElementRef,
comment_id: Option<&str>,
index: usize,
context: &CommentExtractionContext,
) -> Option<AurComment> {
let header_text = header.text().collect::<String>();
let author = header_text.find(" commented on ").map_or_else(
|| {
header_text
.split_whitespace()
.next()
.unwrap_or("Unknown")
.to_string()
},
|pos| header_text[..pos].trim().to_string(),
);
let base_url = format!("https://aur.archlinux.org/packages/{}", context.pkgname);
let (date_text, date_url) = header.select(context.date_selector).next().map_or_else(
|| (String::new(), None),
|e| {
let text = e.text().collect::<String>().trim().to_string();
let url = e.value().attr("href").map(|href| {
if href.starts_with("http://") || href.starts_with("https://") {
href.to_string()
} else if href.starts_with('#') {
format!("{base_url}{href}")
} else {
format!("https://aur.archlinux.org{href}")
}
});
(text, url)
},
);
let comment_content = comment_id
.and_then(|id| id.strip_prefix("comment-"))
.and_then(|comment_id_str| {
Selector::parse(&format!("div#comment-{comment_id_str}-content")).ok()
})
.and_then(|content_id_selector| context.document.select(&content_id_selector).next())
.map_or_else(String::new, |div| {
html_to_formatted_text(div)
});
if comment_content.is_empty() && author == "Unknown" {
return None;
}
let date_timestamp = parse_date_to_timestamp(&date_text);
if date_timestamp.is_none() && !date_text.is_empty() {
debug!(
pkgname = %context.pkgname,
author = %author,
date_text = %date_text,
"Failed to parse comment date to timestamp"
);
}
let local_date = convert_utc_to_local_date(&date_text);
let is_pinned = determine_pinned_status(comment_id, index, context);
let stable_id = comment_id.map(str::to_string).or_else(|| date_url.clone());
Some(AurComment {
id: stable_id,
author,
date: local_date,
date_timestamp,
date_url,
content: comment_content,
pinned: is_pinned,
})
}
fn determine_pinned_status(
comment_id: Option<&str>,
index: usize,
context: &CommentExtractionContext,
) -> bool {
if !context.has_pinned_section {
return false;
}
let Some(latest_pos) = context.latest_comments_pos else {
return false;
};
comment_id.map_or(index < 10, |id| {
context
.html_text
.find(id)
.map_or(index < 10, |comment_pos| comment_pos < latest_pos)
})
}
fn separate_and_sort_comments(comments: Vec<AurComment>) -> Vec<AurComment> {
let mut pinned_comments: Vec<AurComment> =
comments.iter().filter(|c| c.pinned).cloned().collect();
let mut regular_comments: Vec<AurComment> =
comments.into_iter().filter(|c| !c.pinned).collect();
sort_comments_by_date(&mut pinned_comments);
sort_comments_by_date(&mut regular_comments);
pinned_comments.extend(regular_comments);
pinned_comments
}
fn sort_comments_by_date(comments: &mut [AurComment]) {
comments.sort_by(|a, b| {
match (a.date_timestamp, b.date_timestamp) {
(Some(ts_a), Some(ts_b)) => ts_b.cmp(&ts_a), (Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => b.date.cmp(&a.date), }
});
}
fn convert_utc_to_local_date(utc_date_str: &str) -> String {
let utc_date_str = utc_date_str.trim();
if let Some(tz_start) = utc_date_str.rfind('(') {
let date_time_part = utc_date_str[..tz_start].trim();
if let Ok(naive_dt) =
chrono::NaiveDateTime::parse_from_str(date_time_part, "%Y-%m-%d %H:%M")
{
let utc_dt = naive_dt.and_utc();
let local_dt = utc_dt.with_timezone(&chrono::Local);
let formatted = local_dt.format("%Y-%m-%d %H:%M");
let tz_abbr = get_timezone_abbreviation(&local_dt);
return format!("{formatted} ({tz_abbr})");
}
}
utc_date_str.to_string()
}
fn get_timezone_abbreviation(local_dt: &chrono::DateTime<chrono::Local>) -> String {
let tz_from_format = local_dt.format("%Z").to_string();
if !tz_from_format.is_empty()
&& tz_from_format.len() >= 3
&& tz_from_format.len() <= 6
&& tz_from_format.chars().all(char::is_alphabetic)
&& !tz_from_format.starts_with("UTC")
{
return tz_from_format;
}
if let Ok(tz_env) = std::env::var("TZ") {
if let Some(tz_name) = tz_env.rsplit('/').next() {
if tz_name.len() >= 3
&& tz_name.len() <= 6
&& tz_name.chars().all(|c| c.is_uppercase() || c == '-')
{
let abbr = tz_name.split('-').next().unwrap_or(tz_name);
if abbr.len() >= 3 && abbr.chars().all(char::is_alphabetic) {
return abbr.to_string();
}
}
}
}
let offset_secs = local_dt.offset().local_minus_utc();
let hours = offset_secs / 3600;
let minutes = (offset_secs.abs() % 3600) / 60;
if let Some(tz_abbr) = get_tz_abbr_from_offset(hours, local_dt.date_naive()) {
return tz_abbr;
}
if offset_secs == 0 {
"UTC".to_string()
} else if minutes == 0 {
format!("UTC{hours:+}")
} else {
format!("UTC{hours:+}:{minutes:02}")
}
}
fn get_tz_abbr_from_offset(offset_hours: i32, _date: chrono::NaiveDate) -> Option<String> {
match offset_hours {
0 => Some("UTC".to_string()),
_ => None, }
}
fn parse_date_to_timestamp(date_str: &str) -> Option<i64> {
let date_str = date_str.trim();
if date_str.is_empty() {
debug!("Failed to parse empty date string");
return None;
}
if let Some(tz_start) = date_str.rfind('(') {
let date_time_part = date_str[..tz_start].trim();
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(date_time_part, "%Y-%m-%d %H:%M") {
return dt.and_utc().timestamp().into();
}
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(date_time_part, "%Y-%m-%d %H:%M:%S") {
return dt.and_utc().timestamp().into();
}
}
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(date_str, "%Y-%m-%d %H:%M:%S") {
return dt.and_utc().timestamp().into();
}
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(date_str, "%Y-%m-%dT%H:%M:%S") {
return dt.and_utc().timestamp().into();
}
if let Ok(dt) = chrono::DateTime::parse_from_str(date_str, "%Y-%m-%dT%H:%M:%S%z") {
return Some(dt.timestamp());
}
if let Ok(d) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
&& let Some(dt) = d.and_hms_opt(0, 0, 0)
{
return dt.and_utc().timestamp().into();
}
if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(date_str) {
return Some(dt.timestamp());
}
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(date_str) {
return Some(dt.timestamp());
}
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(date_str, "%Y/%m/%d %H:%M") {
return dt.and_utc().timestamp().into();
}
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(date_str, "%d.%m.%Y %H:%M") {
return dt.and_utc().timestamp().into();
}
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(date_str, "%m/%d/%Y %H:%M") {
return dt.and_utc().timestamp().into();
}
if let Ok(ts) = date_str.parse::<i64>() {
if ts > 946_684_800 && ts < 4_102_444_800 {
return Some(ts);
}
}
debug!(
date_str = %date_str,
date_str_len = date_str.len(),
"Failed to parse date string to timestamp"
);
None
}
fn html_to_formatted_text(element: ElementRef) -> String {
let mut result = String::new();
let p_selector = Selector::parse("p").ok();
if let Some(ref p_sel) = p_selector {
let paragraphs: Vec<_> = element.select(p_sel).collect();
if !paragraphs.is_empty() {
for (i, para) in paragraphs.iter().enumerate() {
if i > 0 {
result.push('\n');
}
result.push_str(&convert_element_to_markdown(para));
}
return result.trim().to_string();
}
}
result = convert_element_to_markdown(&element);
result.trim().to_string()
}
fn convert_element_to_markdown(element: &ElementRef) -> String {
let html = element.html();
let mut working_html = html;
let pre_selector = Selector::parse("pre").ok();
if let Some(ref pre_sel) = pre_selector {
for pre in element.select(pre_sel) {
let text = pre.text().collect::<String>();
let pre_html = pre.html();
let replacement = format!("```\n{}\n```", text.trim());
working_html = working_html.replace(&pre_html, &replacement);
}
}
let a_selector = Selector::parse("a").ok();
if let Some(ref a_sel) = a_selector {
for link in element.select(a_sel) {
let text = link.text().collect::<String>().trim().to_string();
if let Some(href) = link.value().attr("href") {
let link_html = link.html();
let replacement = format!("[{text}]({href})");
working_html = working_html.replace(&link_html, &replacement);
}
}
}
let strong_selector = Selector::parse("strong, b").ok();
if let Some(ref strong_sel) = strong_selector {
for bold in element.select(strong_sel) {
let text = bold.text().collect::<String>().trim().to_string();
if !text.is_empty() {
let bold_html = bold.html();
let replacement = format!("**{text}**");
working_html = working_html.replace(&bold_html, &replacement);
}
}
}
let em_selector = Selector::parse("em, i").ok();
if let Some(ref em_sel) = em_selector {
for italic in element.select(em_sel) {
let text = italic.text().collect::<String>().trim().to_string();
if !text.is_empty() {
let italic_html = italic.html();
let replacement = format!("*{text}*");
working_html = working_html.replace(&italic_html, &replacement);
}
}
}
let code_selector = Selector::parse("code").ok();
if let Some(ref code_sel) = code_selector {
for code in element.select(code_sel) {
let text = code.text().collect::<String>().trim().to_string();
if !text.is_empty() {
let code_html = code.html();
let replacement = format!("`{text}`");
working_html = working_html.replace(&code_html, &replacement);
}
}
}
let temp_doc = Html::parse_fragment(&working_html);
let mut result = temp_doc.root_element().text().collect::<String>();
result = result
.replace("<", "<")
.replace(">", ">")
.replace("&", "&")
.replace(""", "\"")
.replace("'", "'")
.replace(" ", " ");
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dst_affected_timezones_return_none() {
let test_cases = vec![
(
chrono::NaiveDate::from_ymd_opt(2024, 3, 1).expect("valid test date"),
-5,
), (
chrono::NaiveDate::from_ymd_opt(2024, 3, 15).expect("valid test date"),
-5,
), (
chrono::NaiveDate::from_ymd_opt(2024, 10, 31).expect("valid test date"),
-5,
), (
chrono::NaiveDate::from_ymd_opt(2024, 11, 4).expect("valid test date"),
-5,
), (
chrono::NaiveDate::from_ymd_opt(2024, 11, 15).expect("valid test date"),
-5,
), (
chrono::NaiveDate::from_ymd_opt(2024, 3, 1).expect("valid test date"),
-6,
), (
chrono::NaiveDate::from_ymd_opt(2024, 3, 1).expect("valid test date"),
-7,
), (
chrono::NaiveDate::from_ymd_opt(2024, 3, 1).expect("valid test date"),
-8,
), (
chrono::NaiveDate::from_ymd_opt(2024, 3, 1).expect("valid test date"),
1,
), (
chrono::NaiveDate::from_ymd_opt(2024, 3, 1).expect("valid test date"),
2,
), ];
for (date, offset) in test_cases {
let result = get_tz_abbr_from_offset(offset, date);
assert!(
result.is_none(),
"Should return None for DST-affected timezones to use UTC offset format. Date: {date:?}, Offset: {offset}, Got: {result:?}"
);
}
}
#[test]
fn test_utc_returns_abbreviation() {
let test_dates = vec![
chrono::NaiveDate::from_ymd_opt(2024, 1, 1).expect("valid test date"),
chrono::NaiveDate::from_ymd_opt(2024, 6, 15).expect("valid test date"),
chrono::NaiveDate::from_ymd_opt(2024, 12, 31).expect("valid test date"),
];
for date in test_dates {
let result = get_tz_abbr_from_offset(0, date);
assert_eq!(
result,
Some("UTC".to_string()),
"UTC should always return 'UTC' abbreviation. Date: {date:?}, Got: {result:?}"
);
}
}
#[test]
fn test_parse_date_to_timestamp() {
assert!(
parse_date_to_timestamp("2025-04-14 11:52 (UTC)").is_some(),
"Should parse UTC format"
);
assert!(
parse_date_to_timestamp("2025-04-14 11:52 (CEST)").is_some(),
"Should parse CEST format"
);
assert!(
parse_date_to_timestamp("2025-04-14 11:52 (UTC+2)").is_some(),
"Should parse UTC+2 format"
);
assert!(
parse_date_to_timestamp("2024-12-01 10:00 (UTC)").is_some(),
"Should parse December date"
);
assert!(
parse_date_to_timestamp("").is_none(),
"Empty string should return None"
);
assert!(
parse_date_to_timestamp("invalid date").is_none(),
"Invalid date should return None"
);
assert!(
parse_date_to_timestamp("2025-04-14 11:52:30").is_some(),
"Should parse ISO 8601-like format with seconds"
);
assert!(
parse_date_to_timestamp("2025-04-14T11:52:30").is_some(),
"Should parse ISO 8601 format with T separator"
);
assert!(
parse_date_to_timestamp("2025-04-14").is_some(),
"Should parse date-only format"
);
assert!(
parse_date_to_timestamp("2025/04/14 11:52").is_some(),
"Should parse format with / separators"
);
assert!(
parse_date_to_timestamp("14.04.2025 11:52").is_some(),
"Should parse DD.MM.YYYY format"
);
assert!(
parse_date_to_timestamp("04/14/2025 11:52").is_some(),
"Should parse MM/DD/YYYY format"
);
assert!(
parse_date_to_timestamp("1735689600").is_some(),
"Should parse Unix timestamp string"
);
if let Some(ts) = parse_date_to_timestamp("2025-04-14 11:52 (UTC)") {
assert!(ts > 0, "Timestamp should be positive");
}
let ts1 = parse_date_to_timestamp("2025-04-14 11:52 (UTC)");
let ts2 = parse_date_to_timestamp("2025-04-14 11:52:00");
assert_eq!(
ts1, ts2,
"Same date/time should produce same timestamp regardless of format"
);
}
}