use crate::sources::news::cache::{ARTICLE_CACHE, ARTICLE_CACHE_TTL_SECONDS, ArticleCacheEntry};
use crate::sources::news::parse::parse_arch_news_html;
use crate::sources::news::utils::is_archlinux_url;
use crate::sources::news::{
aur::extract_aur_pkg_from_url,
cache::{load_article_entry_from_disk_cache, save_article_to_disk_cache},
utils::is_arch_package_url,
};
use crate::state::NewsItem;
use reqwest;
use std::sync::LazyLock;
use std::time::{Duration, Instant};
use tracing::{info, warn};
type Result<T> = super::Result<T>;
fn extract_official_package_cache_path(url: &str) -> Option<std::path::PathBuf> {
let lower = url.to_ascii_lowercase();
let pos = lower.find("archlinux.org/packages/")?;
let after = &url[pos + "archlinux.org/packages/".len()..];
let parts: Vec<&str> = after.split('/').filter(|s| !s.is_empty()).collect();
if parts.len() >= 3 {
let repo = parts[0];
let arch = parts[1];
let name = parts[2]
.split('?')
.next()
.unwrap_or(parts[2])
.split('#')
.next()
.unwrap_or(parts[2]);
Some(crate::sources::official_json_cache_path(repo, arch, name))
} else {
None
}
}
fn prepend_official_package_changes(url: &str, content: &str) -> String {
let Some(cache_path) = extract_official_package_cache_path(url) else {
return content.to_string();
};
let Some(cached_json) = crate::sources::load_official_json_cache(&cache_path) else {
return content.to_string();
};
let pkg_obj = cached_json.get("pkg").unwrap_or(&cached_json);
let Some(pkg_name) = pkg_obj.get("pkgname").and_then(serde_json::Value::as_str) else {
return content.to_string();
};
let Some(changes) = crate::sources::get_official_json_changes(pkg_name) else {
return content.to_string();
};
if content.starts_with("Changes detected") {
content.to_string()
} else {
format!("{changes}\n\n─── Package Info ───\n\n{content}")
}
}
static HTTP_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
use reqwest::header::{ACCEPT, ACCEPT_LANGUAGE, HeaderMap, HeaderValue};
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"));
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(15))
.timeout(Duration::from_secs(30))
.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()
.expect("Failed to create HTTP client")
});
pub async fn fetch_arch_news(limit: usize, cutoff_date: Option<&str>) -> Result<Vec<NewsItem>> {
use crate::sources::news::utils::{extract_between, strip_time_and_tz};
let url = "https://archlinux.org/feeds/news/";
let body = tokio::task::spawn_blocking(move || {
crate::util::curl::curl_text_with_args(
url,
&["--connect-timeout", "10", "--max-time", "15"],
)
})
.await?
.map_err(|e| {
warn!(error = %e, "failed to fetch arch news feed");
e
})?;
info!(bytes = body.len(), "fetched arch news feed");
let mut items: Vec<NewsItem> = Vec::new();
let mut pos = 0;
while items.len() < limit {
if let Some(start) = body[pos..].find("<item>") {
let s = pos + start;
let end = body[s..].find("</item>").map_or(body.len(), |e| s + e + 7);
let chunk = &body[s..end];
let title = extract_between(chunk, "<title>", "</title>").unwrap_or_default();
let link = extract_between(chunk, "<link>", "</link>").unwrap_or_default();
let raw_date = extract_between(chunk, "<pubDate>", "</pubDate>")
.map(|d| d.trim().to_string())
.unwrap_or_default();
let date = strip_time_and_tz(&raw_date);
if let Some(cutoff) = cutoff_date
&& date.as_str() < cutoff
{
break;
}
items.push(NewsItem {
date,
title,
url: link,
});
pos = end;
} else {
break;
}
}
info!(count = items.len(), "parsed arch news feed");
Ok(items)
}
pub async fn fetch_news_content(url: &str) -> Result<String> {
use crate::sources::news::aur::render_aur_comments;
if let Some(pkg) = extract_aur_pkg_from_url(url) {
let changes = crate::sources::get_aur_json_changes(&pkg);
let comments = crate::sources::fetch_aur_comments(pkg.clone()).await?;
let mut rendered = render_aur_comments(&pkg, &comments);
if let Some(changes_text) = changes {
rendered = format!("{changes_text}\n\n─── AUR Comments ───\n\n{rendered}");
}
return Ok(rendered);
}
if is_arch_package_url(url)
&& let Ok(cache) = ARTICLE_CACHE.lock()
&& let Some(entry) = cache.get(url)
&& entry.timestamp.elapsed().as_secs() < ARTICLE_CACHE_TTL_SECONDS
{
let content = prepend_official_package_changes(url, &entry.content);
return Ok(content);
}
let cached_entry: Option<ArticleCacheEntry> = if let Ok(cache) = ARTICLE_CACHE.lock()
&& let Some(entry) = cache.get(url)
&& entry.timestamp.elapsed().as_secs() < ARTICLE_CACHE_TTL_SECONDS
{
info!(url, "using in-memory cached article content");
return Ok(entry.content.clone());
} else {
None
};
let disk_entry = load_article_entry_from_disk_cache(url);
if let Some(ref entry) = disk_entry {
if let Ok(mut cache) = ARTICLE_CACHE.lock() {
cache.insert(
url.to_string(),
ArticleCacheEntry {
content: entry.content.clone(),
timestamp: Instant::now(),
etag: entry.etag.clone(),
last_modified: entry.last_modified.clone(),
},
);
}
if is_arch_package_url(url) {
let content = prepend_official_package_changes(url, &entry.content);
return Ok(content);
}
return Ok(entry.content.clone());
}
let endpoint_pattern = crate::sources::feeds::extract_endpoint_pattern(url);
if let Err(e) = crate::sources::feeds::check_circuit_breaker(&endpoint_pattern) {
warn!(url, endpoint_pattern, error = %e, "circuit breaker blocking request");
if let Some(cached) = cached_entry {
return Ok(cached.content);
}
if let Some(disk) = disk_entry {
return Ok(disk.content);
}
return Err(e);
}
let cached_etag = cached_entry
.as_ref()
.and_then(|e: &ArticleCacheEntry| e.etag.as_ref())
.or_else(|| disk_entry.as_ref().and_then(|e| e.etag.as_ref()))
.cloned();
let cached_last_modified = cached_entry
.as_ref()
.and_then(|e: &ArticleCacheEntry| e.last_modified.as_ref())
.or_else(|| disk_entry.as_ref().and_then(|e| e.last_modified.as_ref()))
.cloned();
let (body, etag, last_modified) =
match fetch_from_network(url, cached_etag, cached_last_modified, &endpoint_pattern).await {
Ok(result) => result,
Err(e) if e.to_string() == "304 Not Modified" => {
if let Some(cached) = cached_entry {
return Ok(cached.content);
}
if let Some(disk) = disk_entry {
return Ok(disk.content);
}
warn!(url, "304 response but no cached content available");
return Err("304 Not Modified but no cache available".into());
}
Err(e) => return Err(e),
};
let content = parse_arch_news_html(&body, Some(url));
let content = if is_arch_package_url(url) {
prepend_official_package_changes(url, &content)
} else {
content
};
let parsed_len = content.len();
if parsed_len == 0 {
warn!(url, "parsed news content is empty");
} else {
info!(url, parsed_len, "parsed news content");
}
if let Ok(mut cache) = ARTICLE_CACHE.lock() {
cache.insert(
url.to_string(),
ArticleCacheEntry {
content: content.clone(),
timestamp: Instant::now(),
etag: etag.clone(),
last_modified: last_modified.clone(),
},
);
}
save_article_to_disk_cache(url, &content, etag, last_modified);
Ok(content)
}
async fn fetch_from_network(
url: &str,
cached_etag: Option<String>,
cached_last_modified: Option<String>,
endpoint_pattern: &str,
) -> Result<(String, Option<String>, Option<String>)> {
let _permit = if is_archlinux_url(url) {
Some(crate::sources::feeds::rate_limit_archlinux().await)
} else {
None
};
let client = HTTP_CLIENT.clone();
let mut request = client.get(url);
if let Some(ref etag) = cached_etag {
request = request.header("If-None-Match", etag);
}
if let Some(ref last_mod) = cached_last_modified {
request = request.header("If-Modified-Since", last_mod);
}
let http_response = request.send().await.map_err(|e| {
warn!(error = %e, url, "failed to fetch news content");
crate::sources::feeds::record_circuit_breaker_outcome(endpoint_pattern, false);
Box::<dyn std::error::Error + Send + Sync>::from(format!("Network error: {e}"))
})?;
let status = http_response.status();
let status_code = status.as_u16();
if status_code == 304 {
info!(
url,
"server returned 304 Not Modified, using cached content"
);
return Err("304 Not Modified".into());
}
let etag = http_response
.headers()
.get("etag")
.and_then(|h| h.to_str().ok())
.map(ToString::to_string);
let last_modified = http_response
.headers()
.get("last-modified")
.and_then(|h| h.to_str().ok())
.map(ToString::to_string);
if status.is_client_error() || status.is_server_error() {
crate::sources::feeds::record_circuit_breaker_outcome(endpoint_pattern, false);
return Err(handle_http_error(status, status_code, &http_response).into());
}
let body = http_response.text().await.map_err(|e| {
warn!(error = %e, url, "failed to read response body");
Box::<dyn std::error::Error + Send + Sync>::from(format!("Failed to read response: {e}"))
})?;
info!(url, bytes = body.len(), "fetched news page");
crate::sources::feeds::record_circuit_breaker_outcome(endpoint_pattern, true);
Ok((body, etag, last_modified))
}
fn handle_http_error(
status: reqwest::StatusCode,
status_code: u16,
http_response: &reqwest::Response,
) -> String {
if status_code == 429 {
let mut msg = "HTTP 429 Too Many Requests - rate limited by server".to_string();
if let Some(retry_after) = http_response.headers().get("retry-after")
&& let Ok(retry_str) = retry_after.to_str()
{
msg.push_str(" (Retry-After: ");
msg.push_str(retry_str);
msg.push(')');
}
msg
} else if status_code == 503 {
let mut msg = "HTTP 503 Service Unavailable".to_string();
if let Some(retry_after) = http_response.headers().get("retry-after")
&& let Ok(retry_str) = retry_after.to_str()
{
msg.push_str(" (Retry-After: ");
msg.push_str(retry_str);
msg.push(')');
}
msg
} else {
format!("HTTP error: {status}")
}
}