using AiAgent.Core.Models;
using System.Net;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using static AiAgent.Core.Helpers.GenericHelper;
namespace AiAgent.Core.Helpers
{
///
/// Fetches and parses RSS/Atom feeds from a list of URLs.
///
public class RssReader
{
private readonly HttpClient _httpClient;
public RssReader()
{
// It's best practice to create a single HttpClient instance and reuse it.
_httpClient = new HttpClient();
// Some feeds may reject requests without a user-agent header.
_httpClient.DefaultRequestHeaders.Add("User-Agent", "My-Awesome-RSS-Reader/1.0");
}
///
/// Reads a file of RSS feed URLs and fetches all articles from them concurrently.
///
/// The path to the text file containing feed URLs.
/// A flat list of all articles from all feeds.
public async Task> FetchArticlesAsync(string filePath)
{
if (File.Exists(filePath) is false)
{
Log($"Error: Source file not found at '{filePath}'");
return [];
}
var feedUrls = await File.ReadAllLinesAsync(filePath);
var allArticles = new List();
Log($"-> Found {feedUrls.Length} feed URLs to process in: {filePath}");
// Create a list of tasks to fetch and parse each feed.
var fetchTasks = feedUrls
.Where(url => !string.IsNullOrWhiteSpace(url))
.Select(url => ParseFeedAsync(url.Trim()))
.ToList();
// Await all tasks to complete. This runs them in parallel.
var results = await Task.WhenAll(fetchTasks);
// Flatten the list of lists into a single list of articles.
foreach (var feedArticles in results)
if (feedArticles != null)
allArticles.AddRange(feedArticles);
// Order articles by publish date, newest first.
return allArticles.OrderByDescending(a => a.PublishDate).ToList();
}
///
/// Parses a single RSS/Atom feed from a given URL.
///
/// The URL of the feed.
/// A list of articles from the feed, or null if an error occurs.
private async Task?> ParseFeedAsync(string feedUrl)
{
try
{
// Get the XML content from the URL.
using var stream = await _httpClient.GetStreamAsync(feedUrl);
var xmlDoc = await XDocument.LoadAsync(stream, LoadOptions.None, default);
// XML namespaces can make querying difficult. This approach ignores them.
var defaultNamespace = xmlDoc.Root?.GetDefaultNamespace();
// Find all item elements. RSS uses "item", Atom uses "entry".
var itemElements = xmlDoc.Descendants(defaultNamespace + "item")
.Concat(xmlDoc.Descendants(defaultNamespace + "entry"));
var articles = itemElements.Select(item =>
{
// Helper function to get element value, ignoring namespaces.
string GetValue(string elementName) => item.Elements().FirstOrDefault(e => e.Name.LocalName == elementName)?.Value ?? string.Empty;
// Atom feeds often have the link in an attribute.
string GetLink()
{
var linkElement = item.Elements().FirstOrDefault(e => e.Name.LocalName == "link");
return linkElement?.Attribute("href")?.Value ?? linkElement?.Value ?? string.Empty;
}
// Try to parse the date from common RSS/Atom date elements.
DateTimeOffset.TryParse(GetValue("pubDate") ?? GetValue("published") ?? GetValue("updated"), out var publishDate);
return new RssArticle
{
FeedSource = feedUrl,
Title = GetValue("title"),
Link = GetLink(),
// Clean the description to remove HTML and decode characters.
Description = CleanHtml(GetValue("description") ?? GetValue("summary")),
PublishDate = publishDate
};
}).ToList();
Log($"-> Successfully parsed {articles.Count} articles from {feedUrl}");
return articles;
}
catch (Exception ex)
{
Log($"Error parsing feed '{feedUrl}': {ex.Message}");
return null; // Return null to indicate failure for this specific feed.
}
}
///
/// Removes HTML tags and decodes HTML entities from a string.
///
/// The HTML string to clean.
/// A plain text string.
private static string CleanHtml(string html)
{
if (string.IsNullOrEmpty(html))
{
return string.Empty;
}
// First, decode HTML entities (e.g., & becomes &, ' becomes ').
var decodedHtml = WebUtility.HtmlDecode(html);
// Second, remove all HTML tags using a regular expression.
var plainText = Regex.Replace(decodedHtml, "<.*?>", string.Empty);
return plainText.Trim();
}
}
}