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(); } } }