feat(News): refactor news service and add sentiment tracking

This commit is contained in:
2026-08-09 21:01:41 +02:00
parent 605e41cfeb
commit aff831cf58
390 changed files with 115094 additions and 9 deletions
@@ -0,0 +1,22 @@
using FinlyticCore.Dtos.News;
namespace FinlyticNews.Adapters.Discovery;
/// <summary>
/// Abstract base class representing an adapter capable of extracting article URLs from web content.
/// </summary>
public abstract class ArticleDiscoveryAdapter
{
/// <summary>
/// Gets the unique adapter keyword name used for matching (e.g. "rss", "html").
/// </summary>
public abstract string Name { get; }
/// <summary>
/// Extracts a list of absolute article URLs from the retrieved web/feed page content.
/// </summary>
/// <param name="content">The raw text or XML content loaded from the source.</param>
/// <param name="sourceUrl">The original URL of the source page (used to resolve relative links).</param>
/// <returns>A list of resolved absolute URLs.</returns>
public abstract List<DiscoveredArticle> ExtractUrls(string content, string sourceUrl);
}
@@ -0,0 +1,93 @@
using System.Text.RegularExpressions;
using System.Xml.Linq;
using FinlyticCore.Dtos.News;
namespace FinlyticNews.Adapters.Discovery;
/// <summary>
/// Specialized RSS discovery adapter for Finanznachrichten.de, capable of parsing custom fn:isin elements and standard RSS metadata.
/// </summary>
public partial class FinanznachrichtenRssDiscoveryAdapter : ArticleDiscoveryAdapter
{
private static readonly XNamespace FnNamespace = "http://www.finanznachrichten.de/service/rss";
/// <inheritdoc />
public override string Name => "finanznachrichten_rss";
/// <inheritdoc />
public override List<DiscoveredArticle> ExtractUrls(string content, string sourceUrl)
{
if (string.IsNullOrWhiteSpace(content)) return [];
var articles = new List<DiscoveredArticle>();
if (!Uri.TryCreate(sourceUrl, UriKind.Absolute, out var baseUri)) return articles;
try
{
var doc = XDocument.Parse(content);
var channel = doc.Root?.Element("channel");
var channelTitle = channel?.Element("title")?.Value?.Trim();
var channelLanguage = channel?.Element("language")?.Value?.Trim();
var items = doc.Descendants("item");
foreach (var item in items)
{
var link = item.Element("link")?.Value?.Trim() ?? item.Element("guid")?.Value?.Trim();
if (string.IsNullOrWhiteSpace(link)) continue;
// ISIN Extraktion
var isins = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var isinEl in item.Elements(FnNamespace + "isin"))
{
if (string.IsNullOrWhiteSpace(isinEl.Value)) continue;
var parts = isinEl.Value.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var part in parts)
{
var trimmed = part.Trim().ToUpperInvariant();
if (IsValidIsin(trimmed))
{
isins.Add(trimmed);
}
}
}
var title = item.Element("title")?.Value?.Trim();
var description = item.Element("description")?.Value?.Trim();
DateTime? pubDate = null;
var pubDateValue = item.Element("pubDate")?.Value;
if (!string.IsNullOrWhiteSpace(pubDateValue) && DateTime.TryParse(pubDateValue, out var parsedDate))
{
pubDate = parsedDate.ToUniversalTime();
}
if (Uri.TryCreate(baseUri, link, out var absoluteUri))
{
articles.Add(new DiscoveredArticle(
Url: absoluteUri.ToString(),
Isins: isins.Count > 0 ? isins.ToList() : null,
Title: title,
Summary: description,
PublishedAt: pubDate,
Language: channelLanguage,
SourceName: channelTitle
));
}
}
}
catch
{
// XML Malformed / Parsing Errors ignorieren
}
// Deduplizierung nach URL
return articles.GroupBy(a => a.Url, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.ToList();
}
private static bool IsValidIsin(string input)
=> input.Length == 12 && char.IsLetter(input[0]) && char.IsLetter(input[1]);
}
@@ -0,0 +1,73 @@
using System.ServiceModel.Syndication;
using System.Xml;
using FinlyticCore.Dtos.News;
namespace FinlyticNews.Adapters.Discovery;
/// <summary>
/// Discovery adapter targeting standard RSS/Atom syndication feeds.
/// </summary>
public class RssDiscoveryAdapter : ArticleDiscoveryAdapter
{
/// <inheritdoc />
public override string Name => "rss";
/// <inheritdoc />
public override List<DiscoveredArticle> ExtractUrls(string content, string sourceUrl)
{
if (string.IsNullOrWhiteSpace(content)) return [];
var articles = new List<DiscoveredArticle>();
if (!Uri.TryCreate(sourceUrl, UriKind.Absolute, out var baseUri)) return articles;
try
{
using var stringReader = new StringReader(content);
using var xmlReader = XmlReader.Create(stringReader, new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Ignore,
IgnoreWhitespace = true
});
var feed = SyndicationFeed.Load(xmlReader);
var feedLanguage = feed?.Language;
var feedTitle = feed?.Title?.Text;
if (feed?.Items != null)
{
foreach (var item in feed.Items)
{
var link = item.Links.FirstOrDefault()?.Uri?.ToString() ?? item.Id;
if (!string.IsNullOrWhiteSpace(link) && Uri.TryCreate(baseUri, link, out var absoluteUri))
{
var title = item.Title?.Text?.Trim();
var summary = item.Summary?.Text?.Trim();
DateTime? pubDate = item.PublishDate != DateTimeOffset.MinValue
? item.PublishDate.UtcDateTime
: (item.LastUpdatedTime != DateTimeOffset.MinValue ? item.LastUpdatedTime.UtcDateTime : null);
articles.Add(new DiscoveredArticle(
Url: absoluteUri.ToString(),
Isins: null,
Title: title,
Summary: summary,
PublishedAt: pubDate,
Language: feedLanguage,
SourceName: feedTitle
));
}
}
}
}
catch
{
// Graceful handling fehlerhafter Feeds
}
return articles.GroupBy(u => u.Url, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.ToList();
}
}