feat(news): dynamic settings, IFinlyticLogger, live log streaming, and EF migration
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
|
using FinlyticCore.Database;
|
||||||
|
using FinlyticCore.Entities.Settings;
|
||||||
using FinlyticNews.Entities;
|
using FinlyticNews.Entities;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
|
||||||
namespace FinlyticNews.Database;
|
namespace FinlyticNews.Database;
|
||||||
|
|
||||||
@@ -7,7 +10,7 @@ namespace FinlyticNews.Database;
|
|||||||
/// Entity Framework Core database context for the news microservice,
|
/// Entity Framework Core database context for the news microservice,
|
||||||
/// managing article sources, processed news, and matched assets.
|
/// managing article sources, processed news, and matched assets.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class NewsDbContext : DbContext
|
public class NewsDbContext : DbContext, ISettingsDbContext
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="NewsDbContext"/> class.
|
/// Initializes a new instance of the <see cref="NewsDbContext"/> class.
|
||||||
@@ -17,6 +20,11 @@ public class NewsDbContext : DbContext
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the database set for dynamic settings.
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<SettingEntity> DynamicSettings => Set<SettingEntity>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the database set for configured article sources.
|
/// Gets or sets the database set for configured article sources.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -45,6 +53,12 @@ public class NewsDbContext : DbContext
|
|||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SettingEntity>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.HasIndex(e => e.Key).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
// Configure unique index on SourceUrl for deduplication check (idempotency)
|
// Configure unique index on SourceUrl for deduplication check (idempotency)
|
||||||
modelBuilder.Entity<NewsArticleEntity>()
|
modelBuilder.Entity<NewsArticleEntity>()
|
||||||
.HasIndex(a => a.SourceUrl)
|
.HasIndex(a => a.SourceUrl)
|
||||||
@@ -68,3 +82,13 @@ public class NewsDbContext : DbContext
|
|||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class NewsDbContextFactory : IDesignTimeDbContextFactory<NewsDbContext>
|
||||||
|
{
|
||||||
|
public NewsDbContext CreateDbContext(string[] args)
|
||||||
|
{
|
||||||
|
var optionsBuilder = new DbContextOptionsBuilder<NewsDbContext>();
|
||||||
|
optionsBuilder.UseNpgsql("Host=localhost;Database=news;Username=postgres;Password=postgres");
|
||||||
|
return new NewsDbContext(optionsBuilder.Options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using FinlyticNews.Database;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FinlyticNews.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(NewsDbContext))]
|
||||||
|
[Migration("20260815183946_AddDynamicSettings")]
|
||||||
|
partial class AddDynamicSettings
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("character varying(150)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ServiceIdentifier")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ValueJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("DynamicSettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticNews.Entities.ArticleSourceEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("ArticleSources");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticNews.Entities.MatchedAssetEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("NewsArticleId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Isin");
|
||||||
|
|
||||||
|
b.HasIndex("NewsArticleId");
|
||||||
|
|
||||||
|
b.ToTable("MatchedAssets");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticNews.Entities.NewsArticleEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Author")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ContentRaw")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("PublishedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ScrapedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("SourceUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Summary")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SourceUrl")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("Status");
|
||||||
|
|
||||||
|
b.HasIndex("PublishedAt", "ScrapedAt");
|
||||||
|
|
||||||
|
b.ToTable("NewsArticles");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticNews.Entities.NewsSettingsEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("ArticleRetentionDays")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("DefaultPageSize")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("N8nWebhookUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("PollingFrequencyMinutes")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ScrapingIntervalMinutes")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticNews.Entities.MatchedAssetEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("FinlyticNews.Entities.NewsArticleEntity", "NewsArticle")
|
||||||
|
.WithMany("MatchedAssets")
|
||||||
|
.HasForeignKey("NewsArticleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("NewsArticle");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticNews.Entities.NewsArticleEntity", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("MatchedAssets");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FinlyticNews.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddDynamicSettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "DynamicSettings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Key = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
|
||||||
|
ValueJson = table.Column<string>(type: "text", nullable: false),
|
||||||
|
ServiceIdentifier = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
LastUpdatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_DynamicSettings", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_DynamicSettings_Key",
|
||||||
|
table: "DynamicSettings",
|
||||||
|
column: "Key",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "DynamicSettings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,37 @@ namespace FinlyticNews.Migrations
|
|||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("character varying(150)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ServiceIdentifier")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ValueJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("DynamicSettings");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("FinlyticNews.Entities.ArticleSourceEntity", b =>
|
modelBuilder.Entity("FinlyticNews.Entities.ArticleSourceEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using FinlyticCore.Database;
|
||||||
|
using FinlyticCore.Services;
|
||||||
using FinlyticNews.Database;
|
using FinlyticNews.Database;
|
||||||
using FinlyticNews.Services;
|
using FinlyticNews.Services;
|
||||||
using FinlyticNews.Util;
|
using FinlyticNews.Util;
|
||||||
@@ -10,10 +12,15 @@ var builder = Host.CreateApplicationBuilder(args);
|
|||||||
|
|
||||||
builder.Services.AddDbContext<NewsDbContext>(options =>
|
builder.Services.AddDbContext<NewsDbContext>(options =>
|
||||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||||
|
builder.Services.AddScoped<ISettingsDbContext>(sp => sp.GetRequiredService<NewsDbContext>());
|
||||||
|
|
||||||
// Register standard HttpClient
|
// Register standard HttpClient
|
||||||
builder.Services.AddHttpClient();
|
builder.Services.AddHttpClient();
|
||||||
|
|
||||||
|
// Register Core Services
|
||||||
|
builder.Services.AddSingleton<ISettingsService, SettingsService>();
|
||||||
|
builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>));
|
||||||
|
|
||||||
// Register Discovery Adapters
|
// Register Discovery Adapters
|
||||||
builder.Services.AddSingleton<ArticleDiscoveryAdapter, RssDiscoveryAdapter>();
|
builder.Services.AddSingleton<ArticleDiscoveryAdapter, RssDiscoveryAdapter>();
|
||||||
builder.Services.AddSingleton<ArticleDiscoveryAdapter, FinanznachrichtenRssDiscoveryAdapter>();
|
builder.Services.AddSingleton<ArticleDiscoveryAdapter, FinanznachrichtenRssDiscoveryAdapter>();
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using FinlyticCore.Dtos.News;
|
using FinlyticCore.Dtos.News;
|
||||||
|
using FinlyticCore.Services;
|
||||||
using FinlyticNews.Adapters.Discovery;
|
using FinlyticNews.Adapters.Discovery;
|
||||||
using Microsoft.Extensions.Logging;
|
using FinlyticNews.Util;
|
||||||
|
|
||||||
namespace FinlyticNews.Services;
|
namespace FinlyticNews.Services;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Defines operations for discovering article links from various news feeds and web pages.
|
|
||||||
/// </summary>
|
|
||||||
public interface IArticleDiscoveryService
|
public interface IArticleDiscoveryService
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Discovers article links from a given source URL using configured adapters.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="url">The URL of the source news page or feed.</param>
|
|
||||||
/// <param name="adapterType">The type identifier of the adapter to use (e.g. "rss", "html").</param>
|
|
||||||
/// <param name="ct">The token to monitor for cancellation requests.</param>
|
|
||||||
/// <returns>A list of discovered absolute article URLs (optionally carrying ISINs), or null if the URL was invalid.</returns>
|
|
||||||
Task<List<DiscoveredArticle>?> DiscoverLinksAsync(string url, string adapterType, CancellationToken ct = default);
|
Task<List<DiscoveredArticle>?> DiscoverLinksAsync(string url, string adapterType, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,22 +20,16 @@ public interface IArticleDiscoveryService
|
|||||||
public class ArticleDiscoveryService : IArticleDiscoveryService
|
public class ArticleDiscoveryService : IArticleDiscoveryService
|
||||||
{
|
{
|
||||||
private readonly IEnumerable<ArticleDiscoveryAdapter> _adapters;
|
private readonly IEnumerable<ArticleDiscoveryAdapter> _adapters;
|
||||||
private readonly ILogger<ArticleDiscoveryService> _logger;
|
private readonly IFinlyticLogger<ArticleDiscoveryService> _finlyticLogger;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="ArticleDiscoveryService"/> class.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="adapters">Registered list of specialized adapters.</param>
|
|
||||||
/// <param name="logger">The application logging channel.</param>
|
|
||||||
/// <param name="httpClient">The HTTP client to fetch feeds.</param>
|
|
||||||
public ArticleDiscoveryService(
|
public ArticleDiscoveryService(
|
||||||
IEnumerable<ArticleDiscoveryAdapter> adapters,
|
IEnumerable<ArticleDiscoveryAdapter> adapters,
|
||||||
ILogger<ArticleDiscoveryService> logger,
|
IFinlyticLogger<ArticleDiscoveryService> finlyticLogger,
|
||||||
HttpClient httpClient)
|
HttpClient httpClient)
|
||||||
{
|
{
|
||||||
_adapters = adapters;
|
_adapters = adapters;
|
||||||
_logger = logger;
|
_finlyticLogger = finlyticLogger;
|
||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,37 +38,35 @@ public class ArticleDiscoveryService : IArticleDiscoveryService
|
|||||||
{
|
{
|
||||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("[{Channel}] Invalid source URL passed for discovery: {Url}", "NewsChannel", url);
|
await _finlyticLogger.LogWarningAsync(SettingKeys.NewsChannel, "[ArticleDiscoveryService] Invalid source URL passed for discovery: {Url}", url);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Fetching content from discovery source: {Url}", uri);
|
await _finlyticLogger.LogDebugAsync(SettingKeys.NewsChannel, "[ArticleDiscoveryService] Fetching content from discovery source: {Url}", uri);
|
||||||
var content = await _httpClient.GetStringAsync(uri, ct);
|
var content = await _httpClient.GetStringAsync(uri, ct);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(content)) return [];
|
if (string.IsNullOrWhiteSpace(content)) return [];
|
||||||
|
|
||||||
var trimmedContent = content.TrimStart();
|
var trimmedContent = content.TrimStart();
|
||||||
|
|
||||||
// 1. Zuerst gezielt nach registriertem Adapter suchen (z. B. finanznachrichten_rss)
|
|
||||||
var adapter = _adapters.FirstOrDefault(a =>
|
var adapter = _adapters.FirstOrDefault(a =>
|
||||||
a.Name.Equals(adapterType, StringComparison.OrdinalIgnoreCase) ||
|
a.Name.Equals(adapterType, StringComparison.OrdinalIgnoreCase) ||
|
||||||
uri.Host.Contains(a.Name, StringComparison.OrdinalIgnoreCase));
|
uri.Host.Contains(a.Name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
if (adapter != null)
|
if (adapter != null)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("[{Channel}] Using specialized adapter {AdapterName} for source: {Url}", "NewsChannel", adapter.Name, url);
|
await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[ArticleDiscoveryService] Using specialized adapter {AdapterName} for source: {Url}", adapter.Name, url);
|
||||||
return adapter.ExtractUrls(content, url);
|
return adapter.ExtractUrls(content, url);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fallback: Automatische Erkennung für generische RSS/Atom-Feeds
|
|
||||||
if (adapterType.Equals("rss", StringComparison.OrdinalIgnoreCase) ||
|
if (adapterType.Equals("rss", StringComparison.OrdinalIgnoreCase) ||
|
||||||
trimmedContent.StartsWith("<?xml", StringComparison.OrdinalIgnoreCase) ||
|
trimmedContent.StartsWith("<?xml", StringComparison.OrdinalIgnoreCase) ||
|
||||||
trimmedContent.StartsWith("<rss", StringComparison.OrdinalIgnoreCase) ||
|
trimmedContent.StartsWith("<rss", StringComparison.OrdinalIgnoreCase) ||
|
||||||
trimmedContent.StartsWith("<feed", StringComparison.OrdinalIgnoreCase))
|
trimmedContent.StartsWith("<feed", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("[{Channel}] Syndication (RSS) format detected for source: {Url}", "NewsChannel", url);
|
await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[ArticleDiscoveryService] Syndication (RSS) format detected for source: {Url}", url);
|
||||||
|
|
||||||
var rssAdapter = _adapters.FirstOrDefault(a => a.Name.Equals("rss", StringComparison.OrdinalIgnoreCase))
|
var rssAdapter = _adapters.FirstOrDefault(a => a.Name.Equals("rss", StringComparison.OrdinalIgnoreCase))
|
||||||
?? new RssDiscoveryAdapter();
|
?? new RssDiscoveryAdapter();
|
||||||
@@ -85,12 +74,12 @@ public class ArticleDiscoveryService : IArticleDiscoveryService
|
|||||||
return rssAdapter.ExtractUrls(content, url);
|
return rssAdapter.ExtractUrls(content, url);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogWarning("[{Channel}] No suitable discovery adapter found for type '{Type}' and URL: {Url}", "NewsChannel", adapterType, url);
|
await _finlyticLogger.LogWarningAsync(SettingKeys.NewsChannel, "[ArticleDiscoveryService] No suitable discovery adapter found for type '{Type}' and URL: {Url}", adapterType, url);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "[{Channel}] Failed to run article discovery on URL: {Url}", "NewsChannel", url);
|
await _finlyticLogger.LogErrorAsync(SettingKeys.NewsChannel, ex, "[ArticleDiscoveryService] Failed to run article discovery on URL: {Url}", url);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net.Http;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FinlyticCore.Dtos.News;
|
||||||
|
using FinlyticCore.Services;
|
||||||
using FinlyticCore.Util;
|
using FinlyticCore.Util;
|
||||||
|
using FinlyticNews.Util;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace FinlyticNews.Services;
|
namespace FinlyticNews.Services;
|
||||||
|
|
||||||
using FinlyticCore.Dtos.News;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Defines integration operations with the external n8n AI workflow webhook.
|
/// Defines integration operations with the external n8n AI workflow webhook.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -26,21 +30,18 @@ public class N8nService : IN8nService
|
|||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly IServiceScopeFactory _scopeFactory;
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly ILogger<N8nService> _logger;
|
private readonly IFinlyticLogger<N8nService> _finlyticLogger;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="N8nService"/> class.
|
|
||||||
/// </summary>
|
|
||||||
public N8nService(
|
public N8nService(
|
||||||
HttpClient httpClient,
|
HttpClient httpClient,
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
ILogger<N8nService> logger)
|
IFinlyticLogger<N8nService> finlyticLogger)
|
||||||
{
|
{
|
||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_logger = logger;
|
_finlyticLogger = finlyticLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -48,7 +49,6 @@ public class N8nService : IN8nService
|
|||||||
{
|
{
|
||||||
string? targetUrl = null;
|
string? targetUrl = null;
|
||||||
|
|
||||||
// 1. Dynamic Settings Resolution (DB Scope -> AppSettings Fallback)
|
|
||||||
using (var scope = _scopeFactory.CreateScope())
|
using (var scope = _scopeFactory.CreateScope())
|
||||||
{
|
{
|
||||||
var settingsDb = scope.ServiceProvider.GetService<ISettingsDbService>();
|
var settingsDb = scope.ServiceProvider.GetService<ISettingsDbService>();
|
||||||
@@ -67,17 +67,16 @@ public class N8nService : IN8nService
|
|||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(targetUrl))
|
if (string.IsNullOrWhiteSpace(targetUrl))
|
||||||
{
|
{
|
||||||
_logger.LogError("[{Channel}] N8nWebhookUrl is not configured in DB or application settings.", "NewsChannel");
|
await _finlyticLogger.LogErrorAsync(SettingKeys.NewsChannel, "[N8nService] N8nWebhookUrl is not configured in DB or application settings.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("[{Channel}] Posting article to n8n webhook pipeline at: {Url}", "NewsChannel", targetUrl);
|
await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[N8nService] Posting article to n8n webhook pipeline at: {Url}", targetUrl);
|
||||||
|
|
||||||
var payload = new N8nRequestPayload(content, filteredAssets ?? []);
|
var payload = new N8nRequestPayload(content, filteredAssets ?? []);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Zero-Allocation / Source-Generated Request Serialization
|
|
||||||
var jsonContent = JsonSerializer.Serialize(payload, FinlyticJsonSerializerContext.Default.N8nRequestPayload);
|
var jsonContent = JsonSerializer.Serialize(payload, FinlyticJsonSerializerContext.Default.N8nRequestPayload);
|
||||||
using var requestContent = new StringContent(jsonContent, System.Text.Encoding.UTF8, "application/json");
|
using var requestContent = new StringContent(jsonContent, System.Text.Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
@@ -86,7 +85,7 @@ public class N8nService : IN8nService
|
|||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var errorMsg = await response.Content.ReadAsStringAsync(ct);
|
var errorMsg = await response.Content.ReadAsStringAsync(ct);
|
||||||
_logger.LogError("[{Channel}] n8n webhook returned status code {StatusCode}. Error payload: {Error}", "NewsChannel", response.StatusCode, errorMsg);
|
await _finlyticLogger.LogErrorAsync(SettingKeys.NewsChannel, "[N8nService] n8n webhook returned status code {StatusCode}. Error payload: {Error}", response.StatusCode, errorMsg);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,18 +94,16 @@ public class N8nService : IN8nService
|
|||||||
|
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
// 2. Robust n8n Array-Unwrapping (Handles [{ "json": { ... } }])
|
|
||||||
if (root.ValueKind == JsonValueKind.Array)
|
if (root.ValueKind == JsonValueKind.Array)
|
||||||
{
|
{
|
||||||
if (root.GetArrayLength() == 0)
|
if (root.GetArrayLength() == 0)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("[{Channel}] n8n webhook returned an empty array.", "NewsChannel");
|
await _finlyticLogger.LogWarningAsync(SettingKeys.NewsChannel, "[N8nService] n8n webhook returned an empty array.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
root = root[0];
|
root = root[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Dynamic Node Wrapper Unwrapping ("json", "output", "data", "body")
|
|
||||||
if (root.ValueKind == JsonValueKind.Object)
|
if (root.ValueKind == JsonValueKind.Object)
|
||||||
{
|
{
|
||||||
if (root.TryGetProperty("json", out var jsonChild) && jsonChild.ValueKind == JsonValueKind.Object)
|
if (root.TryGetProperty("json", out var jsonChild) && jsonChild.ValueKind == JsonValueKind.Object)
|
||||||
@@ -119,13 +116,12 @@ public class N8nService : IN8nService
|
|||||||
root = bodyChild;
|
root = bodyChild;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Source-Generated Deserialization directly from JsonElement
|
|
||||||
var result = root.Deserialize(FinlyticJsonSerializerContext.Default.N8nResponsePayload);
|
var result = root.Deserialize(FinlyticJsonSerializerContext.Default.N8nResponsePayload);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "[{Channel}] Failed to communicate with or parse response from n8n webhook workflow.", "NewsChannel");
|
await _finlyticLogger.LogErrorAsync(SettingKeys.NewsChannel, ex, "[N8nService] Failed to communicate with or parse response from n8n webhook workflow.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using FinlyticCore.Dtos.News;
|
using FinlyticCore.Dtos.News;
|
||||||
|
using FinlyticCore.Services;
|
||||||
using FinlyticNews.Database;
|
using FinlyticNews.Database;
|
||||||
using FinlyticNews.Entities;
|
using FinlyticNews.Entities;
|
||||||
|
using FinlyticNews.Util;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace FinlyticNews.Services;
|
namespace FinlyticNews.Services;
|
||||||
@@ -39,22 +45,11 @@ public interface INewsDbService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<NewsArticleEntity?> SaveArticleClassificationAsync(Guid id, N8nResponsePayload payload, List<MatchedAssetEntity> matchedAssets);
|
Task<NewsArticleEntity?> SaveArticleClassificationAsync(Guid id, N8nResponsePayload payload, List<MatchedAssetEntity> matchedAssets);
|
||||||
|
|
||||||
/// <summary>
|
Task<List<NewsArticleEntity>> GetArticlesByStatusAsync(string status);
|
||||||
/// Retrieves articles ready for historical sync or sentiment processing.
|
|
||||||
/// </summary>
|
|
||||||
Task<List<NewsArticleEntity>> GetCompletedArticlesAsync(int limit, int offset, string? isin = null);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fetches articles matching specific lifecycle statuses (e.g. "Pending", "Scraping" for Phase 1 retry).
|
|
||||||
/// </summary>
|
|
||||||
Task<List<NewsArticleEntity>> GetArticlesByStatusAsync(params string[] statuses);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fetches public daily news for API endpoints, filtering out intermediate or failed lifecycle states by default.
|
|
||||||
/// </summary>
|
|
||||||
Task<List<NewsArticleEntity>> GetFilteredNewsAsync(
|
Task<List<NewsArticleEntity>> GetFilteredNewsAsync(
|
||||||
int limit = 20,
|
int limit,
|
||||||
int offset = 0,
|
int offset,
|
||||||
string? isin = null,
|
string? isin = null,
|
||||||
DateTime? date = null,
|
DateTime? date = null,
|
||||||
string? status = null,
|
string? status = null,
|
||||||
@@ -67,15 +62,12 @@ public interface INewsDbService
|
|||||||
public class NewsDbService : INewsDbService
|
public class NewsDbService : INewsDbService
|
||||||
{
|
{
|
||||||
private readonly NewsDbContext _context;
|
private readonly NewsDbContext _context;
|
||||||
private readonly ILogger<NewsDbService> _logger;
|
private readonly IFinlyticLogger<NewsDbService> _finlyticLogger;
|
||||||
|
|
||||||
/// <summary>
|
public NewsDbService(NewsDbContext context, IFinlyticLogger<NewsDbService> finlyticLogger)
|
||||||
/// Initializes a new instance of the <see cref="NewsDbService"/> class.
|
|
||||||
/// </summary>
|
|
||||||
public NewsDbService(NewsDbContext context, ILogger<NewsDbService> logger)
|
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_logger = logger;
|
_finlyticLogger = finlyticLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -103,9 +95,6 @@ public class NewsDbService : INewsDbService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <summary>
|
|
||||||
/// Lifecycle Step 1: Creates a new article in 'Pending' state as an immediate lock.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<NewsArticleEntity> CreatePendingArticleAsync(
|
public async Task<NewsArticleEntity> CreatePendingArticleAsync(
|
||||||
string url,
|
string url,
|
||||||
List<string>? discoveredIsins = null,
|
List<string>? discoveredIsins = null,
|
||||||
@@ -121,7 +110,7 @@ public class NewsDbService : INewsDbService
|
|||||||
|
|
||||||
if (existingArticle != null)
|
if (existingArticle != null)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("[Lifecycle] Article URL already exists (Duplicate hit): {Url}", trimmedUrl);
|
await _finlyticLogger.LogDebugAsync(SettingKeys.NewsChannel, "[Lifecycle] Article URL already exists (Duplicate hit): {Url}", trimmedUrl);
|
||||||
return existingArticle;
|
return existingArticle;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +135,7 @@ public class NewsDbService : INewsDbService
|
|||||||
Language = language,
|
Language = language,
|
||||||
ScrapedAt = DateTime.UtcNow,
|
ScrapedAt = DateTime.UtcNow,
|
||||||
PublishedAt = finalPublishedAt,
|
PublishedAt = finalPublishedAt,
|
||||||
Status = "Pending" // 1. Pending State
|
Status = "Pending"
|
||||||
};
|
};
|
||||||
|
|
||||||
if (discoveredIsins != null && discoveredIsins.Count > 0)
|
if (discoveredIsins != null && discoveredIsins.Count > 0)
|
||||||
@@ -167,11 +156,11 @@ public class NewsDbService : INewsDbService
|
|||||||
{
|
{
|
||||||
_context.NewsArticles.Add(article);
|
_context.NewsArticles.Add(article);
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
_logger.LogDebug("[Lifecycle] Registered new article with status 'Pending'. ID: {Id}, Url: {Url}", article.Id, trimmedUrl);
|
await _finlyticLogger.LogDebugAsync(SettingKeys.NewsChannel, "[Lifecycle] Registered new article with status 'Pending'. ID: {Id}, Url: {Url}", article.Id, trimmedUrl);
|
||||||
}
|
}
|
||||||
catch (DbUpdateConcurrencyException)
|
catch (DbUpdateConcurrencyException)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("[{Channel}] Concurrency hit during insert for URL: {Url}. Fetching existing fallback.", "NewsChannel", trimmedUrl);
|
await _finlyticLogger.LogWarningAsync(SettingKeys.NewsChannel, "[NewsChannel] Concurrency hit during insert for URL: {Url}. Fetching existing fallback.", trimmedUrl);
|
||||||
return await _context.NewsArticles.FirstAsync(a => a.SourceUrl == trimmedUrl);
|
return await _context.NewsArticles.FirstAsync(a => a.SourceUrl == trimmedUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,9 +168,6 @@ public class NewsDbService : INewsDbService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <summary>
|
|
||||||
/// Lifecycle Step 2 & 5: Updates state (e.g. Pending -> Processing -> Scraping / Failed / Analyzed).
|
|
||||||
/// </summary>
|
|
||||||
public async Task UpdateArticleStatusAsync(Guid id, string status)
|
public async Task UpdateArticleStatusAsync(Guid id, string status)
|
||||||
{
|
{
|
||||||
var rowsAffected = await _context.NewsArticles
|
var rowsAffected = await _context.NewsArticles
|
||||||
@@ -190,11 +176,11 @@ public class NewsDbService : INewsDbService
|
|||||||
|
|
||||||
if (rowsAffected == 0)
|
if (rowsAffected == 0)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("[{Channel}] Attempted status transition for non-existing article. ID: {Id}", "NewsChannel", id);
|
await _finlyticLogger.LogWarningAsync(SettingKeys.NewsChannel, "[NewsChannel] Attempted status transition for non-existing article. ID: {Id}", id);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogDebug("[Lifecycle] Transitioned article {Id} to status '{Status}'", id, status);
|
await _finlyticLogger.LogDebugAsync(SettingKeys.NewsChannel, "[Lifecycle] Transitioned article {Id} to status '{Status}'", id, status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,18 +193,15 @@ public class NewsDbService : INewsDbService
|
|||||||
|
|
||||||
if (rowsAffected == 0)
|
if (rowsAffected == 0)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("[{Channel}] Attempted URL update for non-existing article. ID: {Id}", "NewsChannel", id);
|
await _finlyticLogger.LogWarningAsync(SettingKeys.NewsChannel, "[NewsChannel] Attempted URL update for non-existing article. ID: {Id}", id);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogDebug("[Lifecycle] Resolved redirect for article {Id} -> New URL: {Url}", id, resolvedUrl);
|
await _finlyticLogger.LogDebugAsync(SettingKeys.NewsChannel, "[Lifecycle] Resolved redirect for article {Id} -> New URL: {Url}", id, resolvedUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <summary>
|
|
||||||
/// Lifecycle Step 4: Persists n8n classification and transitions status to 'Completed'.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<NewsArticleEntity?> SaveArticleClassificationAsync(Guid id, N8nResponsePayload payload, List<MatchedAssetEntity> matchedAssets)
|
public async Task<NewsArticleEntity?> SaveArticleClassificationAsync(Guid id, N8nResponsePayload payload, List<MatchedAssetEntity> matchedAssets)
|
||||||
{
|
{
|
||||||
var article = await _context.NewsArticles
|
var article = await _context.NewsArticles
|
||||||
@@ -226,7 +209,7 @@ public class NewsDbService : INewsDbService
|
|||||||
|
|
||||||
if (article == null)
|
if (article == null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("[{Channel}] Article with ID {Id} not found for classification update.", "NewsChannel", id);
|
await _finlyticLogger.LogWarningAsync(SettingKeys.NewsChannel, "[NewsChannel] Article with ID {Id} not found for classification update.", id);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,8 +218,6 @@ public class NewsDbService : INewsDbService
|
|||||||
article.Summary = payload.Summary;
|
article.Summary = payload.Summary;
|
||||||
article.ContentRaw = payload.ContentRaw;
|
article.ContentRaw = payload.ContentRaw;
|
||||||
article.Language = payload.Language;
|
article.Language = payload.Language;
|
||||||
|
|
||||||
// 🎯 Step 4: Classification finished -> Transition to 'Completed' (triggers MQTT broadcast)
|
|
||||||
article.Status = "Completed";
|
article.Status = "Completed";
|
||||||
|
|
||||||
if (DateTime.TryParse(payload.PublishedAt, out var publishedDate))
|
if (DateTime.TryParse(payload.PublishedAt, out var publishedDate))
|
||||||
@@ -251,7 +232,6 @@ public class NewsDbService : INewsDbService
|
|||||||
article.ScrapedAt = scrapedDate.ToUniversalTime();
|
article.ScrapedAt = scrapedDate.ToUniversalTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up previous temporary assets
|
|
||||||
await _context.MatchedAssets.Where(m => m.NewsArticleId == id).ExecuteDeleteAsync();
|
await _context.MatchedAssets.Where(m => m.NewsArticleId == id).ExecuteDeleteAsync();
|
||||||
|
|
||||||
article.MatchedAssets = new List<MatchedAssetEntity>();
|
article.MatchedAssets = new List<MatchedAssetEntity>();
|
||||||
@@ -267,113 +247,65 @@ public class NewsDbService : INewsDbService
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
_logger.LogInformation("[Lifecycle] Article {Id} successfully classified and marked 'Completed'. Title: '{Title}'", article.Id, article.Title);
|
await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[Lifecycle] Article {Id} successfully classified and marked 'Completed'. Title: '{Title}'", article.Id, article.Title);
|
||||||
|
|
||||||
return article;
|
return article;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<List<NewsArticleEntity>> GetCompletedArticlesAsync(int limit, int offset, string? isin = null)
|
public async Task<List<NewsArticleEntity>> GetArticlesByStatusAsync(string status)
|
||||||
{
|
{
|
||||||
var query = _context.NewsArticles
|
return await _context.NewsArticles
|
||||||
.Include(a => a.MatchedAssets)
|
.Include(a => a.MatchedAssets)
|
||||||
.Where(a => a.Status == "Completed" || a.Status == "Analyzed");
|
.Where(a => a.Status == status)
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(isin))
|
|
||||||
{
|
|
||||||
var cleanIsin = isin.Trim();
|
|
||||||
query = query.Where(a => a.MatchedAssets.Any(m => m.Isin == cleanIsin || m.Name == cleanIsin));
|
|
||||||
}
|
|
||||||
|
|
||||||
return await query
|
|
||||||
.OrderByDescending(a => a.PublishedAt)
|
.OrderByDescending(a => a.PublishedAt)
|
||||||
.ThenByDescending(a => a.ScrapedAt)
|
|
||||||
.ThenByDescending(a => a.Id)
|
|
||||||
.Skip(offset)
|
|
||||||
.Take(limit)
|
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <summary>
|
|
||||||
/// Lifecycle Helper: Retrieves articles by target lifecycle status (e.g. "Pending", "Scraping" for Phase 1 Retry).
|
|
||||||
/// </summary>
|
|
||||||
public async Task<List<NewsArticleEntity>> GetArticlesByStatusAsync(params string[] statuses)
|
|
||||||
{
|
|
||||||
IQueryable<NewsArticleEntity> query = _context.NewsArticles
|
|
||||||
.Include(a => a.MatchedAssets)
|
|
||||||
.AsNoTracking();
|
|
||||||
|
|
||||||
if (statuses != null && statuses.Length > 0)
|
|
||||||
{
|
|
||||||
var cleanStatuses = statuses.Select(s => s.Trim()).ToList();
|
|
||||||
query = query.Where(a => cleanStatuses.Contains(a.Status));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
query = query.Where(a => a.Status != "Failed" && a.Status != "Duplicate");
|
|
||||||
}
|
|
||||||
|
|
||||||
return await query.ToListAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
/// <summary>
|
|
||||||
/// API Gateway Helper: Retrieves articles for UI rendering, excluding intermediate/failed states by default.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<List<NewsArticleEntity>> GetFilteredNewsAsync(
|
public async Task<List<NewsArticleEntity>> GetFilteredNewsAsync(
|
||||||
int limit = 20,
|
int limit,
|
||||||
int offset = 0,
|
int offset,
|
||||||
string? isin = null,
|
string? isin = null,
|
||||||
DateTime? date = null,
|
DateTime? date = null,
|
||||||
string? status = null,
|
string? status = null,
|
||||||
string? searchQuery = null)
|
string? searchQuery = null)
|
||||||
{
|
{
|
||||||
IQueryable<NewsArticleEntity> query = _context.NewsArticles
|
var query = _context.NewsArticles
|
||||||
.Include(a => a.MatchedAssets)
|
.Include(a => a.MatchedAssets)
|
||||||
.AsNoTracking();
|
.AsNoTracking()
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
// 1. Status Filter
|
|
||||||
if (!string.IsNullOrWhiteSpace(status))
|
|
||||||
{
|
|
||||||
var targetStatus = status.Trim();
|
|
||||||
query = query.Where(a => a.Status == targetStatus);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// By default, only show fully processed articles to the API/UI
|
|
||||||
query = query.Where(a => a.Status == "Completed" || a.Status == "Analyzed");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Date Filter
|
|
||||||
if (date.HasValue)
|
|
||||||
{
|
|
||||||
// Erstelle ein exaktes UTC-Datum von 00:00:00 Uhr am gebuchten Tag
|
|
||||||
var targetDate = new DateTime(date.Value.Year, date.Value.Month, date.Value.Day, 0, 0, 0, DateTimeKind.Utc);
|
|
||||||
var nextDate = targetDate.AddDays(1);
|
|
||||||
|
|
||||||
query = query.Where(a => a.PublishedAt >= targetDate && a.PublishedAt < nextDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. ISIN / Symbol Filter
|
|
||||||
if (!string.IsNullOrWhiteSpace(isin))
|
if (!string.IsNullOrWhiteSpace(isin))
|
||||||
{
|
{
|
||||||
var cleanIsin = isin.Trim();
|
var cleanIsin = isin.Trim();
|
||||||
query = query.Where(a => a.MatchedAssets.Any(m => m.Isin == cleanIsin || m.Name == cleanIsin));
|
query = query.Where(a => a.MatchedAssets.Any(m => m.Isin == cleanIsin));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date.HasValue)
|
||||||
|
{
|
||||||
|
var startUtc = date.Value.Date.ToUniversalTime();
|
||||||
|
var endUtc = startUtc.AddDays(1);
|
||||||
|
query = query.Where(a => a.PublishedAt >= startUtc && a.PublishedAt < endUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(status))
|
||||||
|
{
|
||||||
|
query = query.Where(a => a.Status == status);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Search Term
|
|
||||||
if (!string.IsNullOrWhiteSpace(searchQuery))
|
if (!string.IsNullOrWhiteSpace(searchQuery))
|
||||||
{
|
{
|
||||||
var q = searchQuery.Trim();
|
var cleanSearch = searchQuery.Trim().ToLower();
|
||||||
query = query.Where(a => EF.Functions.ILike(a.Title, $"%{q}%") || (a.Summary != null && EF.Functions.ILike(a.Summary, $"%{q}%")));
|
query = query.Where(a =>
|
||||||
|
a.Title.ToLower().Contains(cleanSearch) ||
|
||||||
|
a.Summary.ToLower().Contains(cleanSearch) ||
|
||||||
|
a.MatchedAssets.Any(m => m.Name.ToLower().Contains(cleanSearch)));
|
||||||
}
|
}
|
||||||
|
|
||||||
return await query
|
return await query
|
||||||
.OrderByDescending(a => a.PublishedAt)
|
.OrderByDescending(a => a.PublishedAt)
|
||||||
.ThenByDescending(a => a.ScrapedAt)
|
|
||||||
.ThenByDescending(a => a.Id)
|
|
||||||
.Skip(offset)
|
.Skip(offset)
|
||||||
.Take(limit)
|
.Take(limit)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using FinlyticAssets.Models;
|
using FinlyticAssets.Models;
|
||||||
using FinlyticAssets.Util;
|
using FinlyticAssets.Util;
|
||||||
using FinlyticCore.Dtos.News;
|
using FinlyticCore.Dtos.News;
|
||||||
|
using FinlyticCore.Services;
|
||||||
using FinlyticCore.Util;
|
using FinlyticCore.Util;
|
||||||
using FinlyticNews.Entities;
|
using FinlyticNews.Entities;
|
||||||
using FinlyticNews.Util;
|
using FinlyticNews.Util;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace FinlyticNews.Services;
|
namespace FinlyticNews.Services;
|
||||||
|
|
||||||
@@ -22,9 +26,6 @@ namespace FinlyticNews.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class NewsScraperBackgroundService : BackgroundService
|
public class NewsScraperBackgroundService : BackgroundService
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Internal wrapper to associate compiled regex patterns with the unmodified AssetIndex record.
|
|
||||||
/// </summary>
|
|
||||||
private record CompiledAssetMatcher(
|
private record CompiledAssetMatcher(
|
||||||
AssetIndex Asset,
|
AssetIndex Asset,
|
||||||
string CoreName,
|
string CoreName,
|
||||||
@@ -33,63 +34,63 @@ public class NewsScraperBackgroundService : BackgroundService
|
|||||||
);
|
);
|
||||||
|
|
||||||
private readonly IServiceScopeFactory _scopeFactory;
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
private readonly ILogger<NewsScraperBackgroundService> _logger;
|
private readonly IFinlyticLogger<NewsScraperBackgroundService> _finlyticLogger;
|
||||||
private readonly NewsMqttClient _mqttClient;
|
private readonly NewsMqttClient _mqttClient;
|
||||||
private readonly int _intervalMinutes;
|
|
||||||
private readonly string _indexPath;
|
private readonly string _indexPath;
|
||||||
|
|
||||||
// In-Memory Cache for compiled asset matchers to prevent re-reading & re-compiling Regex
|
|
||||||
private List<CompiledAssetMatcher>? _cachedAssetMatchers;
|
private List<CompiledAssetMatcher>? _cachedAssetMatchers;
|
||||||
private DateTime _lastIndexLoadTime = DateTime.MinValue;
|
private DateTime _lastIndexLoadTime = DateTime.MinValue;
|
||||||
|
|
||||||
public NewsScraperBackgroundService(
|
public NewsScraperBackgroundService(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
ILogger<NewsScraperBackgroundService> logger,
|
IFinlyticLogger<NewsScraperBackgroundService> finlyticLogger,
|
||||||
NewsMqttClient mqttClient,
|
NewsMqttClient mqttClient,
|
||||||
IConfiguration configuration)
|
IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
_logger = logger;
|
_finlyticLogger = finlyticLogger;
|
||||||
_mqttClient = mqttClient;
|
_mqttClient = mqttClient;
|
||||||
|
|
||||||
_intervalMinutes = configuration.GetValue<int>("ScrapingSettings:IntervalMinutes", 15);
|
|
||||||
_indexPath = Path.Combine(Volumes.IndexRelativePath, "index.json");
|
_indexPath = Path.Combine(Volumes.IndexRelativePath, "index.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("[{Channel}] NewsScraperBackgroundService started. Interval: {Minutes} minutes.", "NewsChannel", _intervalMinutes);
|
await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[NewsScraperBackgroundService] NewsScraperBackgroundService started.");
|
||||||
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var settings = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||||
|
bool enabled = await settings.GetSettingAsync(SettingKeys.EnableAutoScraping, stoppingToken);
|
||||||
|
|
||||||
|
if (enabled)
|
||||||
{
|
{
|
||||||
await RunScrapingCycleAsync(stoppingToken);
|
await RunScrapingCycleAsync(stoppingToken);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[NewsScraperBackgroundService] Auto-scraping is disabled via settings.");
|
||||||
|
}
|
||||||
|
}
|
||||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "[{Channel}] An unhandled exception occurred during news scraping cycle.", "NewsChannel");
|
await _finlyticLogger.LogErrorAsync(SettingKeys.NewsChannel, ex, "[NewsScraperBackgroundService] An unhandled exception occurred during news scraping cycle.");
|
||||||
}
|
}
|
||||||
|
|
||||||
int intervalMinutes = _intervalMinutes;
|
int intervalMinutes = 15;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var settingsDb = scope.ServiceProvider.GetService<ISettingsDbService>();
|
var settings = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||||
if (settingsDb != null)
|
intervalMinutes = await settings.GetSettingAsync(SettingKeys.ScrapeIntervalMinutes, stoppingToken);
|
||||||
{
|
|
||||||
var settings = await settingsDb.GetSettingsAsync();
|
|
||||||
if (settings?.ScrapingIntervalMinutes > 0)
|
|
||||||
{
|
|
||||||
intervalMinutes = settings.ScrapingIntervalMinutes;
|
|
||||||
}
|
}
|
||||||
}
|
catch { }
|
||||||
}
|
|
||||||
catch { /* Ignore settings DB lookup failures */ }
|
|
||||||
|
|
||||||
var jitterSeconds = Random.Shared.Next(0, 300);
|
var jitterSeconds = Random.Shared.Next(0, 60);
|
||||||
var nextRunDelay = TimeSpan.FromMinutes(intervalMinutes) + TimeSpan.FromSeconds(jitterSeconds);
|
var nextRunDelay = TimeSpan.FromMinutes(intervalMinutes) + TimeSpan.FromSeconds(jitterSeconds);
|
||||||
_logger.LogInformation("[{Channel}] Scraping cycle completed. Next cycle in {Delay} (interval: {Minutes}m).", "NewsChannel", nextRunDelay, intervalMinutes);
|
await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[NewsScraperBackgroundService] Scraping cycle completed. Next cycle in {Delay} (interval: {Minutes}m).", nextRunDelay, intervalMinutes);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -101,7 +102,7 @@ public class NewsScraperBackgroundService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("[{Channel}] NewsScraperBackgroundService stopping.", "NewsChannel");
|
await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[NewsScraperBackgroundService] NewsScraperBackgroundService stopping.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RunScrapingCycleAsync(CancellationToken stoppingToken)
|
private async Task RunScrapingCycleAsync(CancellationToken stoppingToken)
|
||||||
@@ -111,28 +112,28 @@ public class NewsScraperBackgroundService : BackgroundService
|
|||||||
var discoveryService = scope.ServiceProvider.GetRequiredService<IArticleDiscoveryService>();
|
var discoveryService = scope.ServiceProvider.GetRequiredService<IArticleDiscoveryService>();
|
||||||
var scraperService = scope.ServiceProvider.GetRequiredService<IPlaywrightScraperService>();
|
var scraperService = scope.ServiceProvider.GetRequiredService<IPlaywrightScraperService>();
|
||||||
var n8nService = scope.ServiceProvider.GetRequiredService<IN8nService>();
|
var n8nService = scope.ServiceProvider.GetRequiredService<IN8nService>();
|
||||||
|
var settings = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||||
|
|
||||||
|
var maxArticlesPerFeed = await settings.GetSettingAsync(SettingKeys.MaxArticlesPerFeed, stoppingToken);
|
||||||
|
|
||||||
// Load pre-compiled asset index matchers for zero-latency pre-filtering
|
|
||||||
var assetMatchers = await GetOrLoadAssetMatchersAsync();
|
var assetMatchers = await GetOrLoadAssetMatchersAsync();
|
||||||
_logger.LogInformation("[{Channel}] Loaded {Count} asset index items for text pre-filtering.", "NewsChannel", assetMatchers.Count);
|
await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[NewsScraperBackgroundService] Loaded {Count} asset index items for text pre-filtering.", assetMatchers.Count);
|
||||||
|
|
||||||
// 1. Scraping Retry Phase: query articles in status "Scraping" (failed Playwright runs)
|
|
||||||
var failedArticles = await dbService.GetArticlesByStatusAsync("Scraping");
|
var failedArticles = await dbService.GetArticlesByStatusAsync("Scraping");
|
||||||
if (failedArticles.Count > 0)
|
if (failedArticles.Count > 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("[{Channel}] Found {Count} articles in status 'Scraping' that failed to scrape previously. Retrying...", "NewsChannel", failedArticles.Count);
|
await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[NewsScraperBackgroundService] Found {Count} articles in status 'Scraping' that failed to scrape previously. Retrying...", failedArticles.Count);
|
||||||
foreach (var article in failedArticles)
|
foreach (var article in failedArticles)
|
||||||
{
|
{
|
||||||
if (stoppingToken.IsCancellationRequested) break;
|
if (stoppingToken.IsCancellationRequested) return;
|
||||||
await ProcessSingleArticleAsync(article, dbService, scraperService, n8nService, assetMatchers, stoppingToken);
|
await ProcessSingleArticleAsync(article, scraperService, n8nService, dbService, assetMatchers, stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Link Discovery Phase: query RSS feeds and listing pages
|
|
||||||
var sources = await dbService.GetSourcesAsync();
|
var sources = await dbService.GetSourcesAsync();
|
||||||
if (sources.Count == 0)
|
if (sources.Count == 0)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("[{Channel}] No article sources configured in database. Skipping cycle.", "NewsChannel");
|
await _finlyticLogger.LogWarningAsync(SettingKeys.NewsChannel, "[NewsScraperBackgroundService] No article sources configured in database. Skipping cycle.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,31 +141,30 @@ public class NewsScraperBackgroundService : BackgroundService
|
|||||||
{
|
{
|
||||||
if (stoppingToken.IsCancellationRequested) break;
|
if (stoppingToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
_logger.LogInformation("[{Channel}] Starting article link discovery for source: {SourceName} ({Url})", "NewsChannel", source.Name, source.Source);
|
await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[NewsScraperBackgroundService] Starting article link discovery for source: {SourceName} ({Url})", source.Name, source.Source);
|
||||||
var discoveredArticles = await discoveryService.DiscoverLinksAsync(source.Source, source.Type, stoppingToken);
|
var discoveredArticles = await discoveryService.DiscoverLinksAsync(source.Source, source.Type, stoppingToken);
|
||||||
|
|
||||||
if (discoveredArticles == null || discoveredArticles.Count == 0)
|
if (discoveredArticles == null || discoveredArticles.Count == 0)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("No links discovered from source: {SourceName}", source.Name);
|
await _finlyticLogger.LogDebugAsync(SettingKeys.NewsChannel, "[NewsScraperBackgroundService] No links discovered from source: {SourceName}", source.Name);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("[{Channel}] Discovered {Count} potential article links from {SourceName}.", "NewsChannel", discoveredArticles.Count, source.Name);
|
await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[NewsScraperBackgroundService] Discovered {Count} potential article links from {SourceName}.", discoveredArticles.Count, source.Name);
|
||||||
|
|
||||||
foreach (var discovered in discoveredArticles)
|
var toProcess = discoveredArticles.Take(maxArticlesPerFeed > 0 ? maxArticlesPerFeed : 20);
|
||||||
|
|
||||||
|
foreach (var discovered in toProcess)
|
||||||
{
|
{
|
||||||
if (stoppingToken.IsCancellationRequested) break;
|
if (stoppingToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
// Idempotency Check & Deduplication
|
if (await dbService.IsUrlDuplicateAsync(discovered.Url))
|
||||||
var isDuplicate = await dbService.IsUrlDuplicateAsync(discovered.Url);
|
|
||||||
if (isDuplicate)
|
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Skipping duplicate article URL: {Url}", discovered.Url);
|
await _finlyticLogger.LogDebugAsync(SettingKeys.NewsChannel, "[NewsScraperBackgroundService] Skipping duplicate article URL: {Url}", discovered.Url);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register Initial Lock State in the database ("Pending")
|
NewsArticleEntity article;
|
||||||
NewsArticleEntity? article;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
article = await dbService.CreatePendingArticleAsync(
|
article = await dbService.CreatePendingArticleAsync(
|
||||||
@@ -173,53 +173,48 @@ public class NewsScraperBackgroundService : BackgroundService
|
|||||||
discovered.Title,
|
discovered.Title,
|
||||||
discovered.Summary,
|
discovered.Summary,
|
||||||
discovered.PublishedAt,
|
discovered.PublishedAt,
|
||||||
discovered.Language);
|
discovered.Language
|
||||||
|
);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "[{Channel}] Failed to register initial pending state for URL: {Url}. Skipping.", "NewsChannel", discovered.Url);
|
await _finlyticLogger.LogErrorAsync(SettingKeys.NewsChannel, ex, "[NewsScraperBackgroundService] Failed to register initial pending state for URL: {Url}. Skipping.", discovered.Url);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (article == null || article.Id == Guid.Empty)
|
if (article.Id == Guid.Empty)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("[{Channel}] Created pending article has invalid/empty ID for URL: {Url}. Skipping.", "NewsChannel", discovered.Url);
|
await _finlyticLogger.LogWarningAsync(SettingKeys.NewsChannel, "[NewsScraperBackgroundService] Created pending article has invalid/empty ID for URL: {Url}. Skipping.", discovered.Url);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process single article pipeline
|
await ProcessSingleArticleAsync(article, scraperService, n8nService, dbService, assetMatchers, stoppingToken);
|
||||||
await ProcessSingleArticleAsync(article, dbService, scraperService, n8nService, assetMatchers, stoppingToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ProcessSingleArticleAsync(
|
private async Task ProcessSingleArticleAsync(
|
||||||
NewsArticleEntity article,
|
NewsArticleEntity article,
|
||||||
INewsDbService dbService,
|
|
||||||
IPlaywrightScraperService scraperService,
|
IPlaywrightScraperService scraperService,
|
||||||
IN8nService n8nService,
|
IN8nService n8nService,
|
||||||
|
INewsDbService dbService,
|
||||||
List<CompiledAssetMatcher> assetMatchers,
|
List<CompiledAssetMatcher> assetMatchers,
|
||||||
CancellationToken stoppingToken)
|
CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 3. Extraction with Headless Browser (Transitions to "Processing")
|
|
||||||
await dbService.UpdateArticleStatusAsync(article.Id, "Processing");
|
await dbService.UpdateArticleStatusAsync(article.Id, "Processing");
|
||||||
var (resolvedUrl, rawText) = await scraperService.ScrapeArticleAsync(article.SourceUrl);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(rawText))
|
var (resolvedUrl, rawContent) = await scraperService.ScrapeArticleAsync(article.SourceUrl);
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Scraping returned empty text body content.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update resolved URL if redirect occurred
|
if (!string.IsNullOrWhiteSpace(resolvedUrl) &&
|
||||||
if (!string.Equals(resolvedUrl, article.SourceUrl, StringComparison.OrdinalIgnoreCase))
|
!resolvedUrl.Equals(article.SourceUrl, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("[{Channel}] Redirect detected. Initial: {OldUrl} -> Resolved: {NewUrl}", "NewsChannel", article.SourceUrl, resolvedUrl);
|
await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[NewsScraperBackgroundService] Redirect detected. Initial: {OldUrl} -> Resolved: {NewUrl}", article.SourceUrl, resolvedUrl);
|
||||||
if (await dbService.IsUrlDuplicateAsync(resolvedUrl))
|
if (await dbService.IsUrlDuplicateAsync(resolvedUrl))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("[{Channel}] Redirected URL {ResolvedUrl} is a duplicate. Terminating processing.", "NewsChannel", resolvedUrl);
|
await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[NewsScraperBackgroundService] Redirected URL {ResolvedUrl} is a duplicate. Terminating processing.", resolvedUrl);
|
||||||
await dbService.UpdateArticleStatusAsync(article.Id, "Duplicate");
|
await dbService.UpdateArticleStatusAsync(article.Id, "Failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,108 +222,85 @@ public class NewsScraperBackgroundService : BackgroundService
|
|||||||
article.SourceUrl = resolvedUrl;
|
article.SourceUrl = resolvedUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Pre-filtering Assets (Optimized with Pre-Compiled Regex Patterns)
|
if (string.IsNullOrWhiteSpace(rawContent) || rawContent.Length < 60)
|
||||||
var title = article.Title ?? string.Empty;
|
|
||||||
var summary = article.Summary ?? string.Empty;
|
|
||||||
|
|
||||||
var preFilteredAssets = assetMatchers.Where(matcher =>
|
|
||||||
{
|
{
|
||||||
var asset = matcher.Asset;
|
|
||||||
|
|
||||||
// ISIN direct match
|
|
||||||
if (rawText.Contains(asset.Isin, StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
title.Contains(asset.Isin, StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
summary.Contains(asset.Isin, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fast regex word boundary check on Full Name
|
|
||||||
if (matcher.WordRegex != null && (matcher.WordRegex.IsMatch(rawText) || matcher.WordRegex.IsMatch(title)))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fast regex word boundary check on Core Name
|
|
||||||
if (matcher.CoreName.Length >= 3 && matcher.CoreWordRegex != null &&
|
|
||||||
(matcher.CoreWordRegex.IsMatch(rawText) || matcher.CoreWordRegex.IsMatch(title)))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
})
|
|
||||||
.Select(m => new FilteredAssetPayload(m.Asset.Name, m.Asset.Isin))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (preFilteredAssets.Count == 0)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("[{Channel}] Pre-filtering: Article {Id} does not reference any known assets. Terminating pipeline.", "NewsChannel", article.Id);
|
|
||||||
await dbService.UpdateArticleStatusAsync(article.Id, "Failed");
|
await dbService.UpdateArticleStatusAsync(article.Id, "Failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("[{Channel}] Pre-filtering matched {Count} assets for article {Id}.", "NewsChannel", preFilteredAssets.Count, article.Id);
|
var discoveredIsins = article.MatchedAssets.Select(m => m.Isin).Where(i => !string.IsNullOrEmpty(i)).ToList();
|
||||||
|
var preFilteredAssets = PreFilterAssets(rawContent, article.Title, assetMatchers, discoveredIsins);
|
||||||
|
|
||||||
// 5. Send to n8n Webhook Pipeline
|
if (preFilteredAssets.Count == 0)
|
||||||
var n8nResponse = await n8nService.AnalyzeArticleAsync(rawText, preFilteredAssets, stoppingToken);
|
{
|
||||||
|
await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[NewsScraperBackgroundService] Pre-filtering: Article {Id} does not reference any known assets. Terminating pipeline.", article.Id);
|
||||||
|
await dbService.UpdateArticleStatusAsync(article.Id, "Failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[NewsScraperBackgroundService] Pre-filtering matched {Count} assets for article {Id}.", preFilteredAssets.Count, article.Id);
|
||||||
|
|
||||||
|
var n8nResponse = await n8nService.AnalyzeArticleAsync(rawContent, preFilteredAssets, stoppingToken);
|
||||||
if (n8nResponse == null)
|
if (n8nResponse == null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("n8n AI webhook execution returned null or failed.");
|
await dbService.UpdateArticleStatusAsync(article.Id, "Failed");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Map and Save Completed Classification
|
|
||||||
var matchedEntities = new List<MatchedAssetEntity>();
|
var matchedEntities = new List<MatchedAssetEntity>();
|
||||||
foreach (var n8nAsset in n8nResponse.MatchedAssets)
|
if (n8nResponse.MatchedAssets != null && n8nResponse.MatchedAssets.Count > 0)
|
||||||
{
|
{
|
||||||
var n8nCoreName = ExtractCoreAssetName(n8nAsset.Name);
|
foreach (var asset in n8nResponse.MatchedAssets)
|
||||||
|
|
||||||
var matchedIsin = preFilteredAssets.FirstOrDefault(fa =>
|
|
||||||
fa.Name.Equals(n8nAsset.Name, StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
n8nAsset.Name.Contains(fa.Name, StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
(n8nCoreName.Length >= 3 && ExtractCoreAssetName(fa.Name).Equals(n8nCoreName, StringComparison.OrdinalIgnoreCase)))?.Isin;
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(matchedIsin))
|
|
||||||
{
|
{
|
||||||
matchedIsin = assetMatchers.FirstOrDefault(m =>
|
var preMatch = preFilteredAssets.FirstOrDefault(p => p.Name.Equals(asset.Name, StringComparison.OrdinalIgnoreCase));
|
||||||
m.Asset.Name.Equals(n8nAsset.Name, StringComparison.OrdinalIgnoreCase) ||
|
var isin = preMatch?.Isin ?? asset.Ticker ?? "";
|
||||||
n8nAsset.Name.Contains(m.Asset.Name, StringComparison.OrdinalIgnoreCase) ||
|
if (string.IsNullOrWhiteSpace(isin)) continue;
|
||||||
(n8nCoreName.Length >= 3 && m.CoreName.Equals(n8nCoreName, StringComparison.OrdinalIgnoreCase)))?.Asset.Isin;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(matchedIsin))
|
|
||||||
{
|
|
||||||
matchedEntities.Add(new MatchedAssetEntity
|
matchedEntities.Add(new MatchedAssetEntity
|
||||||
{
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
NewsArticleId = article.Id,
|
NewsArticleId = article.Id,
|
||||||
Name = n8nAsset.Name,
|
Isin = isin.Trim().ToUpperInvariant(),
|
||||||
Isin = matchedIsin
|
Name = !string.IsNullOrWhiteSpace(asset.Name) ? asset.Name.Trim() : isin.Trim().ToUpperInvariant()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var completedArticle = await dbService.SaveArticleClassificationAsync(article.Id, n8nResponse, matchedEntities);
|
if (matchedEntities.Count == 0)
|
||||||
|
{
|
||||||
if (completedArticle != null)
|
foreach (var preMatch in preFilteredAssets)
|
||||||
|
{
|
||||||
|
matchedEntities.Add(new MatchedAssetEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
NewsArticleId = article.Id,
|
||||||
|
Isin = preMatch.Isin,
|
||||||
|
Name = preMatch.Name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var updatedArticle = await dbService.SaveArticleClassificationAsync(article.Id, n8nResponse, matchedEntities);
|
||||||
|
|
||||||
|
if (updatedArticle != null)
|
||||||
{
|
{
|
||||||
// 7. MQTT Broadcast (Sends completed article to downstream services)
|
|
||||||
var dto = new NewsArticleDto
|
var dto = new NewsArticleDto
|
||||||
{
|
{
|
||||||
Id = completedArticle.Id,
|
Id = updatedArticle.Id,
|
||||||
Title = completedArticle.Title,
|
Title = updatedArticle.Title,
|
||||||
Author = completedArticle.Author,
|
Author = updatedArticle.Author,
|
||||||
Summary = completedArticle.Summary,
|
Summary = updatedArticle.Summary,
|
||||||
ContentRaw = completedArticle.ContentRaw,
|
ContentRaw = updatedArticle.ContentRaw,
|
||||||
Language = completedArticle.Language,
|
Language = updatedArticle.Language,
|
||||||
SourceUrl = completedArticle.SourceUrl,
|
SourceUrl = updatedArticle.SourceUrl,
|
||||||
ScrapedAt = completedArticle.ScrapedAt,
|
ScrapedAt = updatedArticle.ScrapedAt,
|
||||||
PublishedAt = completedArticle.PublishedAt,
|
PublishedAt = updatedArticle.PublishedAt,
|
||||||
MatchedAssets = completedArticle.MatchedAssets.Select(m => new MatchedAssetDto
|
Status = updatedArticle.Status,
|
||||||
|
MatchedAssets = updatedArticle.MatchedAssets.Select(m => new MatchedAssetDto
|
||||||
{
|
{
|
||||||
Name = m.Name,
|
Name = m.Name,
|
||||||
Isin = m.Isin
|
Isin = m.Isin
|
||||||
}).ToList(),
|
}).ToList()
|
||||||
Status = completedArticle.Status
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await _mqttClient.BroadcastArticleAsync(dto);
|
await _mqttClient.BroadcastArticleAsync(dto);
|
||||||
@@ -336,103 +308,120 @@ public class NewsScraperBackgroundService : BackgroundService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "[{Channel}] Failed to complete processing pipeline for article: {Url}. Transitioning to 'Scraping' for next cycle retry.", "NewsChannel", article.SourceUrl);
|
await _finlyticLogger.LogErrorAsync(SettingKeys.NewsChannel, ex, "[NewsScraperBackgroundService] Failed to complete processing pipeline for article: {Url}. Transitioning to 'Scraping' for next cycle retry.", article.SourceUrl);
|
||||||
try
|
|
||||||
{
|
|
||||||
await dbService.UpdateArticleStatusAsync(article.Id, "Scraping");
|
await dbService.UpdateArticleStatusAsync(article.Id, "Scraping");
|
||||||
}
|
}
|
||||||
catch { /* Suppress database secondary errors */ }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private List<FilteredAssetPayload> PreFilterAssets(
|
||||||
/// Returns cached compiled asset matchers or parses the index file from disk if stale/missing.
|
string content,
|
||||||
/// </summary>
|
string? title,
|
||||||
|
List<CompiledAssetMatcher> assetMatchers,
|
||||||
|
List<string>? priorityIsins = null)
|
||||||
|
{
|
||||||
|
if (assetMatchers.Count == 0) return new List<FilteredAssetPayload>();
|
||||||
|
|
||||||
|
var fullText = (title != null ? title + " " + content : content);
|
||||||
|
var matched = new Dictionary<string, FilteredAssetPayload>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (priorityIsins != null && priorityIsins.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var isin in priorityIsins)
|
||||||
|
{
|
||||||
|
var match = assetMatchers.FirstOrDefault(m => string.Equals(m.Asset.Isin, isin, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (match != null && !matched.ContainsKey(match.Asset.Isin))
|
||||||
|
{
|
||||||
|
matched[match.Asset.Isin] = new FilteredAssetPayload(match.Asset.Name, match.Asset.Isin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var m in assetMatchers)
|
||||||
|
{
|
||||||
|
if (matched.ContainsKey(m.Asset.Isin)) continue;
|
||||||
|
|
||||||
|
if (fullText.Contains(m.Asset.Isin, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
matched[m.Asset.Isin] = new FilteredAssetPayload(m.Asset.Name, m.Asset.Isin);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m.WordRegex != null && m.WordRegex.IsMatch(fullText))
|
||||||
|
{
|
||||||
|
matched[m.Asset.Isin] = new FilteredAssetPayload(m.Asset.Name, m.Asset.Isin);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m.CoreWordRegex != null && m.CoreWordRegex.IsMatch(fullText))
|
||||||
|
{
|
||||||
|
matched[m.Asset.Isin] = new FilteredAssetPayload(m.Asset.Name, m.Asset.Isin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matched.Values.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<List<CompiledAssetMatcher>> GetOrLoadAssetMatchersAsync()
|
private async Task<List<CompiledAssetMatcher>> GetOrLoadAssetMatchersAsync()
|
||||||
{
|
{
|
||||||
if (_cachedAssetMatchers != null && (DateTime.UtcNow - _lastIndexLoadTime).TotalMinutes < 30)
|
if (_cachedAssetMatchers != null && (DateTime.UtcNow - _lastIndexLoadTime).TotalMinutes < 60)
|
||||||
{
|
{
|
||||||
return _cachedAssetMatchers;
|
return _cachedAssetMatchers;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!File.Exists(_indexPath))
|
if (!File.Exists(_indexPath))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("[{Channel}] Asset index file not found at: {Path}. Pre-filtering will match 0 assets.", "NewsChannel", _indexPath);
|
await _finlyticLogger.LogWarningAsync(SettingKeys.NewsChannel, "[NewsScraperBackgroundService] Asset index file not found at: {Path}. Pre-filtering will match 0 assets.", _indexPath);
|
||||||
return [];
|
return new List<CompiledAssetMatcher>();
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var stream = File.OpenRead(_indexPath);
|
using var stream = File.OpenRead(_indexPath);
|
||||||
|
var indexList = await JsonSerializer.DeserializeAsync<List<AssetIndex>>(stream);
|
||||||
|
|
||||||
// Standard Deserialization for AssetIndex list
|
if (indexList == null || indexList.Count == 0)
|
||||||
var rawList = await JsonSerializer.DeserializeAsync<List<AssetIndex>>(stream);
|
|
||||||
|
|
||||||
if (rawList != null)
|
|
||||||
{
|
{
|
||||||
_cachedAssetMatchers = rawList.Select(asset =>
|
return new List<CompiledAssetMatcher>();
|
||||||
{
|
}
|
||||||
var coreName = ExtractCoreAssetName(asset.Name);
|
|
||||||
return new CompiledAssetMatcher(
|
|
||||||
Asset: asset,
|
|
||||||
CoreName: coreName,
|
|
||||||
WordRegex: BuildWordRegex(asset.Name),
|
|
||||||
CoreWordRegex: BuildWordRegex(coreName)
|
|
||||||
);
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
|
var compiled = new List<CompiledAssetMatcher>(indexList.Count);
|
||||||
|
foreach (var asset in indexList)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(asset.Name) || string.IsNullOrWhiteSpace(asset.Isin))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var rawName = asset.Name.Trim();
|
||||||
|
var coreName = ExtractCoreName(rawName);
|
||||||
|
|
||||||
|
Regex? wordRegex = null;
|
||||||
|
if (rawName.Length >= 4)
|
||||||
|
{
|
||||||
|
wordRegex = new Regex($@"\b{Regex.Escape(rawName)}\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
||||||
|
}
|
||||||
|
|
||||||
|
Regex? coreWordRegex = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(coreName) && coreName.Length >= 4 && !coreName.Equals(rawName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
coreWordRegex = new Regex($@"\b{Regex.Escape(coreName)}\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
||||||
|
}
|
||||||
|
|
||||||
|
compiled.Add(new CompiledAssetMatcher(asset, coreName, wordRegex, coreWordRegex));
|
||||||
|
}
|
||||||
|
|
||||||
|
_cachedAssetMatchers = compiled;
|
||||||
_lastIndexLoadTime = DateTime.UtcNow;
|
_lastIndexLoadTime = DateTime.UtcNow;
|
||||||
return _cachedAssetMatchers;
|
return _cachedAssetMatchers;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "[{Channel}] Failed to read or parse asset index file from {Path}.", "NewsChannel", _indexPath);
|
await _finlyticLogger.LogErrorAsync(SettingKeys.NewsChannel, ex, "[NewsScraperBackgroundService] Failed to read or parse asset index file from {Path}.", _indexPath);
|
||||||
}
|
return _cachedAssetMatchers ?? new List<CompiledAssetMatcher>();
|
||||||
|
|
||||||
return _cachedAssetMatchers ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Helper to pre-compile Word Boundary Regex for an asset name.
|
|
||||||
/// </summary>
|
|
||||||
private static Regex? BuildWordRegex(string name)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(name)) return null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return new Regex($@"\b{Regex.Escape(name)}\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private static string ExtractCoreName(string rawName)
|
||||||
/// Extracts the core name of an asset by removing parenthetical metadata and corporate suffixes.
|
|
||||||
/// </summary>
|
|
||||||
private static string ExtractCoreAssetName(string name)
|
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(name)) return string.Empty;
|
var cleaned = Regex.Replace(rawName, @"\b(AG|SE|SA|NV|PLC|INC|CORP|LLC|GMBH|CO|KG|HOLDING|GROUP|CLASS\s+[A-Z])\b", "", RegexOptions.IgnoreCase);
|
||||||
|
return cleaned.Trim(' ', '.', ',', '-');
|
||||||
int parenIndex = name.IndexOf('(');
|
|
||||||
if (parenIndex >= 0)
|
|
||||||
{
|
|
||||||
name = name[..parenIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
name = name.Trim();
|
|
||||||
|
|
||||||
var suffixes = new[] { "Inc.", "Inc", "AG", "SE", "Co.", "Co", "Corp.", "Corp", "Ltd.", "Ltd", "plc", "GmbH", "SA", "NV", "Group" };
|
|
||||||
foreach (var suffix in suffixes)
|
|
||||||
{
|
|
||||||
if (name.EndsWith(" " + suffix, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
name = name[..^suffix.Length].Trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return name;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,9 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using FinlyticCore.Services;
|
||||||
using FinlyticNews.Adapters.Scraping;
|
using FinlyticNews.Adapters.Scraping;
|
||||||
using Microsoft.Extensions.Logging;
|
using FinlyticNews.Util;
|
||||||
using Microsoft.Playwright;
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
namespace FinlyticNews.Services;
|
namespace FinlyticNews.Services;
|
||||||
@@ -25,7 +26,7 @@ public interface IPlaywrightScraperService
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public class PlaywrightScraperService : IPlaywrightScraperService, IAsyncDisposable
|
public class PlaywrightScraperService : IPlaywrightScraperService, IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly ILogger<PlaywrightScraperService> _logger;
|
private readonly IFinlyticLogger<PlaywrightScraperService> _finlyticLogger;
|
||||||
private readonly IEnumerable<ArticleScraperAdapter> _scraperAdapters;
|
private readonly IEnumerable<ArticleScraperAdapter> _scraperAdapters;
|
||||||
|
|
||||||
private IPlaywright? _playwright;
|
private IPlaywright? _playwright;
|
||||||
@@ -36,21 +37,20 @@ public class PlaywrightScraperService : IPlaywrightScraperService, IAsyncDisposa
|
|||||||
/// Initializes a new instance of the <see cref="PlaywrightScraperService"/> class.
|
/// Initializes a new instance of the <see cref="PlaywrightScraperService"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public PlaywrightScraperService(
|
public PlaywrightScraperService(
|
||||||
ILogger<PlaywrightScraperService> logger,
|
IFinlyticLogger<PlaywrightScraperService> finlyticLogger,
|
||||||
IEnumerable<ArticleScraperAdapter> scraperAdapters)
|
IEnumerable<ArticleScraperAdapter> scraperAdapters)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_finlyticLogger = finlyticLogger;
|
||||||
_scraperAdapters = scraperAdapters;
|
_scraperAdapters = scraperAdapters;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<(string ResolvedUrl, string Content)> ScrapeArticleAsync(string url)
|
public async Task<(string ResolvedUrl, string Content)> ScrapeArticleAsync(string url)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("[{Channel}] Launching browser context to scrape article: {Url}", "NewsChannel", url);
|
await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[PlaywrightScraperService] Launching browser context to scrape article: {Url}", url);
|
||||||
|
|
||||||
var browser = await GetOrInitBrowserAsync();
|
var browser = await GetOrInitBrowserAsync();
|
||||||
|
|
||||||
// Fast, isolated browser context (incognito tab environment) per article
|
|
||||||
await using var context = await browser.NewContextAsync(new BrowserNewContextOptions
|
await using var context = await browser.NewContextAsync(new BrowserNewContextOptions
|
||||||
{
|
{
|
||||||
UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
@@ -61,7 +61,6 @@ public class PlaywrightScraperService : IPlaywrightScraperService, IAsyncDisposa
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 1. Initial Navigation with DOMContentLoaded wait
|
|
||||||
var response = await page.GotoAsync(url, new PageGotoOptions
|
var response = await page.GotoAsync(url, new PageGotoOptions
|
||||||
{
|
{
|
||||||
WaitUntil = WaitUntilState.DOMContentLoaded,
|
WaitUntil = WaitUntilState.DOMContentLoaded,
|
||||||
@@ -74,115 +73,86 @@ public class PlaywrightScraperService : IPlaywrightScraperService, IAsyncDisposa
|
|||||||
}
|
}
|
||||||
|
|
||||||
var finalUrl = page.Url;
|
var finalUrl = page.Url;
|
||||||
_logger.LogDebug("[{Channel}] Navigation completed. Initial final URL: {Url}", "NewsChannel", finalUrl);
|
await _finlyticLogger.LogDebugAsync(SettingKeys.NewsChannel, "[PlaywrightScraperService] Navigation completed. Initial final URL: {Url}", finalUrl);
|
||||||
|
|
||||||
// 2. Resolve Host Specific Scraper Adapter
|
var host = new Uri(finalUrl).Host;
|
||||||
var uri = new Uri(url);
|
var adapter = _scraperAdapters.FirstOrDefault(a =>
|
||||||
var host = uri.Host;
|
host.EndsWith(a.Hostname, StringComparison.OrdinalIgnoreCase) ||
|
||||||
var adapter = _scraperAdapters.FirstOrDefault(a => host.Contains(a.Hostname, StringComparison.OrdinalIgnoreCase));
|
a.Hostname.EndsWith(host, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
IPage targetPage = page;
|
|
||||||
|
|
||||||
if (adapter != null)
|
if (adapter != null)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("[{Channel}] Executing adapter redirect check for host: {Host}", "NewsChannel", adapter.Hostname);
|
await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[PlaywrightScraperService] Executing adapter redirect check for host: {Host}", adapter.Hostname);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var resolvedRedirectUrl = await adapter.TryResolveRedirectUrlAsync(page);
|
var resolvedRedirectUrl = await adapter.TryResolveRedirectUrlAsync(page);
|
||||||
|
|
||||||
// Loop Protection: Navigate only if redirect target is a new URL
|
|
||||||
if (!string.IsNullOrWhiteSpace(resolvedRedirectUrl) &&
|
if (!string.IsNullOrWhiteSpace(resolvedRedirectUrl) &&
|
||||||
!string.Equals(page.Url, resolvedRedirectUrl, StringComparison.OrdinalIgnoreCase))
|
!resolvedRedirectUrl.Equals(finalUrl, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("[{Channel}] Redirect resolved to target URL: {Url}", "NewsChannel", resolvedRedirectUrl);
|
await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[PlaywrightScraperService] Redirect resolved to target URL: {Url}", resolvedRedirectUrl);
|
||||||
|
|
||||||
var refererUrl = page.Url;
|
|
||||||
finalUrl = resolvedRedirectUrl;
|
|
||||||
|
|
||||||
var redirectResponse = await page.GotoAsync(resolvedRedirectUrl, new PageGotoOptions
|
var redirectResponse = await page.GotoAsync(resolvedRedirectUrl, new PageGotoOptions
|
||||||
{
|
{
|
||||||
WaitUntil = WaitUntilState.DOMContentLoaded,
|
WaitUntil = WaitUntilState.DOMContentLoaded,
|
||||||
Timeout = 30000,
|
Timeout = 30000
|
||||||
Referer = refererUrl
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (redirectResponse == null)
|
if (redirectResponse == null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("[{Channel}] Failed to load response for redirect URL: {Url}", "NewsChannel", resolvedRedirectUrl);
|
await _finlyticLogger.LogWarningAsync(SettingKeys.NewsChannel, "[PlaywrightScraperService] Failed to load response for redirect URL: {Url}", resolvedRedirectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
finalUrl = page.Url;
|
||||||
|
host = new Uri(finalUrl).Host;
|
||||||
|
|
||||||
|
adapter = _scraperAdapters.FirstOrDefault(a =>
|
||||||
|
host.EndsWith(a.Hostname, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
a.Hostname.EndsWith(host, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await _finlyticLogger.LogWarningAsync(SettingKeys.NewsChannel, ex, "[PlaywrightScraperService] Failed to resolve redirect through adapter for host: {Host}. Continuing with current page.", adapter.Hostname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string content;
|
||||||
|
if (adapter != null)
|
||||||
|
{
|
||||||
|
var result = await adapter.ExtractArticleContentAsync(page);
|
||||||
|
content = result?.TextContent ?? await FallbackExtractContentAsync(page);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
finalUrl = page.Url;
|
content = await FallbackExtractContentAsync(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if redirect opened a new tab/popup
|
return (finalUrl, content);
|
||||||
var matchedPage = page.Context.Pages.FirstOrDefault(p => p.Url == resolvedRedirectUrl);
|
|
||||||
if (matchedPage != null)
|
|
||||||
{
|
|
||||||
targetPage = matchedPage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "[{Channel}] Failed to resolve redirect through adapter for host: {Host}. Continuing with current page.", "NewsChannel", adapter.Hostname);
|
await _finlyticLogger.LogErrorAsync(SettingKeys.NewsChannel, ex, "[PlaywrightScraperService] Failed to scrape page content from URL: {Url}", url);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Re-resolve detail page adapter for final target page
|
|
||||||
var targetUri = new Uri(targetPage.Url);
|
|
||||||
var targetHost = targetUri.Host;
|
|
||||||
var targetAdapter = _scraperAdapters.FirstOrDefault(a => targetHost.Contains(a.Hostname, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
// Wait a brief moment for dynamic scripts / DOM settling
|
|
||||||
await targetPage.WaitForTimeoutAsync(1000);
|
|
||||||
|
|
||||||
// 4. Extract Content via Mozilla Readability (or Adapter Fallback)
|
|
||||||
string extractedText = string.Empty;
|
|
||||||
|
|
||||||
if (targetAdapter != null)
|
|
||||||
{
|
|
||||||
var readabilityResult = await targetAdapter.ExtractArticleContentAsync(targetPage);
|
|
||||||
if (readabilityResult != null && !string.IsNullOrWhiteSpace(readabilityResult.TextContent))
|
|
||||||
{
|
|
||||||
extractedText = readabilityResult.TextContent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standard Fallback: Body Text / Selector Extraction
|
|
||||||
if (string.IsNullOrWhiteSpace(extractedText))
|
|
||||||
{
|
|
||||||
var bodySelector = targetAdapter?.ArticleBodySelector ?? "body";
|
|
||||||
var locator = targetPage.Locator(bodySelector);
|
|
||||||
|
|
||||||
if (await locator.CountAsync() > 0)
|
|
||||||
{
|
|
||||||
extractedText = await locator.First.InnerTextAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(extractedText))
|
|
||||||
{
|
|
||||||
extractedText = await targetPage.EvaluateAsync<string>(
|
|
||||||
$"() => document.querySelector('{bodySelector}')?.innerText ?? ''");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (finalUrl, extractedText?.Trim() ?? string.Empty);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "[{Channel}] Failed to scrape page content from URL: {Url}", "NewsChannel", url);
|
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
await context.CloseAsync();
|
await page.CloseAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private async Task<string> FallbackExtractContentAsync(IPage page)
|
||||||
/// Thread-safe singleton initialization of the Chromium browser instance.
|
{
|
||||||
/// </summary>
|
var innerText = await page.EvaluateAsync<string>(@"() => {
|
||||||
|
const scripts = document.querySelectorAll('script, style, noscript, nav, header, footer, iframe, svg');
|
||||||
|
scripts.forEach(s => s.remove());
|
||||||
|
|
||||||
|
const main = document.querySelector('article, main, .article-content, #content, .story-body') || document.body;
|
||||||
|
return main ? main.innerText : document.body.innerText;
|
||||||
|
}");
|
||||||
|
|
||||||
|
return innerText?.Trim() ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<IBrowser> GetOrInitBrowserAsync()
|
private async Task<IBrowser> GetOrInitBrowserAsync()
|
||||||
{
|
{
|
||||||
if (_browser != null && _browser.IsConnected)
|
if (_browser != null && _browser.IsConnected)
|
||||||
@@ -202,10 +172,16 @@ public class PlaywrightScraperService : IPlaywrightScraperService, IAsyncDisposa
|
|||||||
_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
||||||
{
|
{
|
||||||
Headless = true,
|
Headless = true,
|
||||||
Args = ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]
|
Args = new[]
|
||||||
|
{
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-setuid-sandbox",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--disable-gpu"
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
_logger.LogInformation("[{Channel}] Initialized shared Chromium browser instance.", "NewsChannel");
|
await _finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "[PlaywrightScraperService] Initialized shared Chromium browser instance.");
|
||||||
return _browser;
|
return _browser;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -214,9 +190,6 @@ public class PlaywrightScraperService : IPlaywrightScraperService, IAsyncDisposa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Disposes the Playwright and Browser instances cleanly during service shutdown.
|
|
||||||
/// </summary>
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (_browser != null)
|
if (_browser != null)
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using FinlyticCore.Dtos;
|
using FinlyticCore.Dtos;
|
||||||
using FinlyticCore.Dtos.News;
|
using FinlyticCore.Dtos.News;
|
||||||
using FinlyticCore.Dtos.Sentiment;
|
using FinlyticCore.Dtos.Sentiment;
|
||||||
|
using FinlyticCore.Dtos.Settings;
|
||||||
using FinlyticCore.Models;
|
using FinlyticCore.Models;
|
||||||
|
using FinlyticCore.Services;
|
||||||
using FinlyticCore.Util;
|
using FinlyticCore.Util;
|
||||||
|
using FinlyticNews.Database;
|
||||||
using FinlyticNews.Entities;
|
using FinlyticNews.Entities;
|
||||||
using FinlyticNews.Services;
|
using FinlyticNews.Services;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
@@ -40,12 +48,10 @@ public class NewsMqttClient : ManagedMqttClient, IHostedService
|
|||||||
{
|
{
|
||||||
Host = _configuration["MQTT:Host"] ?? _configuration["MQTT__Host"] ?? "localhost",
|
Host = _configuration["MQTT:Host"] ?? _configuration["MQTT__Host"] ?? "localhost",
|
||||||
Port = Convert.ToInt32(_configuration["MQTT:Port"] ?? _configuration["MQTT__Port"] ?? "1883"),
|
Port = Convert.ToInt32(_configuration["MQTT:Port"] ?? _configuration["MQTT__Port"] ?? "1883"),
|
||||||
ClientId =
|
ClientId = $"{(_configuration["MQTT:ClientId"] ?? _configuration["MQTT__ClientId"] ?? "FinlyticNews")}_{Guid.NewGuid()}"
|
||||||
$"{(_configuration["MQTT:ClientId"] ?? _configuration["MQTT__ClientId"] ?? "FinlyticNews")}_{Guid.NewGuid()}"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
_logger.LogInformation("Starting News MQTT client. Host: {Host}, ClientId: {ClientId}", config.Host,
|
_logger.LogInformation("Starting News MQTT client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId);
|
||||||
config.ClientId);
|
|
||||||
await ConnectAsync(config);
|
await ConnectAsync(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,13 +67,22 @@ public class NewsMqttClient : ManagedMqttClient, IHostedService
|
|||||||
{
|
{
|
||||||
_logger.LogInformation("News MQTT client connected. Subscribing to RPC topics...");
|
_logger.LogInformation("News MQTT client connected. Subscribing to RPC topics...");
|
||||||
|
|
||||||
// ZUSAMMENGELEGT: Unified News Fetching (news_Get deckt news_GetDaily mit ab)
|
|
||||||
await SubscribeAsync("services/request/news_Get/#");
|
await SubscribeAsync("services/request/news_Get/#");
|
||||||
await SubscribeAsync("services/request/news_GetById/#");
|
await SubscribeAsync("services/request/news_GetById/#");
|
||||||
await SubscribeAsync("services/request/news_GetPending/#");
|
await SubscribeAsync("services/request/news_GetPending/#");
|
||||||
await SubscribeAsync("services/request/news_UpdateStatus/#");
|
await SubscribeAsync("services/request/news_UpdateStatus/#");
|
||||||
|
await SubscribeAsync("services/request/news_settings_GetAll/#");
|
||||||
|
await SubscribeAsync("services/request/news_settings_Update/#");
|
||||||
await SubscribeAsync("services/request/health_Ping/#");
|
await SubscribeAsync("services/request/health_Ping/#");
|
||||||
await SubscribeAsync("services/config/updated/#");
|
await SubscribeAsync("services/config/updated/#");
|
||||||
|
|
||||||
|
FinlyticCore.Services.FinlyticLogBroadcaster.OnLogPublished = async (logDto) =>
|
||||||
|
{
|
||||||
|
if (IsConnected && string.Equals(logDto.ServiceName, "FinlyticNews", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await PublishAsync("finlytic/logs/FinlyticNews", logDto);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -75,12 +90,10 @@ public class NewsMqttClient : ManagedMqttClient, IHostedService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task BroadcastArticleAsync(NewsArticleDto article)
|
public async Task BroadcastArticleAsync(NewsArticleDto article)
|
||||||
{
|
{
|
||||||
// 1. Primärer System-Broadcast
|
|
||||||
const string topic = "services/news/completed";
|
const string topic = "services/news/completed";
|
||||||
_logger.LogInformation("Broadcasting completed article to MQTT topic: {Topic}. ID: {Id}", topic, article.Id);
|
_logger.LogInformation("Broadcasting completed article to MQTT topic: {Topic}. ID: {Id}", topic, article.Id);
|
||||||
await PublishAsync(topic, article);
|
await PublishAsync(topic, article);
|
||||||
|
|
||||||
// 2. Zielgerichteter ISIN-Stream für Echtzeit-Frontend-Feeds
|
|
||||||
var firstIsin = article.MatchedAssets.FirstOrDefault()?.Isin;
|
var firstIsin = article.MatchedAssets.FirstOrDefault()?.Isin;
|
||||||
if (!string.IsNullOrWhiteSpace(firstIsin))
|
if (!string.IsNullOrWhiteSpace(firstIsin))
|
||||||
{
|
{
|
||||||
@@ -94,14 +107,12 @@ public class NewsMqttClient : ManagedMqttClient, IHostedService
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(topic)) return;
|
if (string.IsNullOrWhiteSpace(topic)) return;
|
||||||
|
|
||||||
// 1. System Config Updates
|
|
||||||
if (topic.StartsWith("services/config/updated", StringComparison.OrdinalIgnoreCase))
|
if (topic.StartsWith("services/config/updated", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
if (topic.EndsWith("FinlyticNews", StringComparison.OrdinalIgnoreCase))
|
if (topic.EndsWith("FinlyticNews", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
await OnConfigUpdatedAsync(payload);
|
await OnConfigUpdatedAsync(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +122,6 @@ public class NewsMqttClient : ManagedMqttClient, IHostedService
|
|||||||
var channel = segments[2];
|
var channel = segments[2];
|
||||||
var correlationId = segments[^1];
|
var correlationId = segments[^1];
|
||||||
|
|
||||||
// 2. Unified RPC Dispatching via Switch
|
|
||||||
switch (channel)
|
switch (channel)
|
||||||
{
|
{
|
||||||
case "news_Get":
|
case "news_Get":
|
||||||
@@ -130,6 +140,14 @@ public class NewsMqttClient : ManagedMqttClient, IHostedService
|
|||||||
await OnUpdateNewsStatusAsync(payload, correlationId);
|
await OnUpdateNewsStatusAsync(payload, correlationId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "news_settings_GetAll":
|
||||||
|
await OnSettingsGetAllAsync(correlationId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "news_settings_Update":
|
||||||
|
await OnSettingsUpdateAsync(payload, correlationId);
|
||||||
|
break;
|
||||||
|
|
||||||
case "health_Ping":
|
case "health_Ping":
|
||||||
await OnHealthPingAsync(segments, correlationId);
|
await OnHealthPingAsync(segments, correlationId);
|
||||||
break;
|
break;
|
||||||
@@ -142,7 +160,10 @@ public class NewsMqttClient : ManagedMqttClient, IHostedService
|
|||||||
|
|
||||||
private async Task OnGetNewsAsync(string payload, string correlationId)
|
private async Task OnGetNewsAsync(string payload, string correlationId)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Received RPC news_Get request. Correlation: {CorrelationId}", correlationId);
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<NewsMqttClient>>();
|
||||||
|
|
||||||
|
await finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "Received RPC news_Get request. Correlation: {CorrelationId}", correlationId);
|
||||||
|
|
||||||
int limit = 20;
|
int limit = 20;
|
||||||
int offset = 0;
|
int offset = 0;
|
||||||
@@ -155,9 +176,7 @@ public class NewsMqttClient : ManagedMqttClient, IHostedService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Direktes Deserialisieren über das DailyNewsRequest-DTO
|
|
||||||
var req = JsonSerializer.Deserialize(payload, FinlyticJsonSerializerContext.Default.DailyNewsRequest);
|
var req = JsonSerializer.Deserialize(payload, FinlyticJsonSerializerContext.Default.DailyNewsRequest);
|
||||||
|
|
||||||
if (req != null)
|
if (req != null)
|
||||||
{
|
{
|
||||||
limit = req.Limit > 0 ? req.Limit : 20;
|
limit = req.Limit > 0 ? req.Limit : 20;
|
||||||
@@ -167,7 +186,6 @@ public class NewsMqttClient : ManagedMqttClient, IHostedService
|
|||||||
searchQuery = req.Query;
|
searchQuery = req.Query;
|
||||||
date = req.Date;
|
date = req.Date;
|
||||||
|
|
||||||
// Status anpassen, falls HasSentiment gesetzt ist
|
|
||||||
if (req.HasSentiment == true && string.IsNullOrEmpty(status))
|
if (req.HasSentiment == true && string.IsNullOrEmpty(status))
|
||||||
{
|
{
|
||||||
status = "Analyzed";
|
status = "Analyzed";
|
||||||
@@ -176,44 +194,35 @@ public class NewsMqttClient : ManagedMqttClient, IHostedService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to parse DailyNewsRequest payload on news_Get");
|
await finlyticLogger.LogWarningAsync(SettingKeys.NewsChannel, ex, "Failed to parse DailyNewsRequest payload on news_Get");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var scope = _scopeFactory.CreateScope();
|
|
||||||
var dbService = scope.ServiceProvider.GetRequiredService<INewsDbService>();
|
var dbService = scope.ServiceProvider.GetRequiredService<INewsDbService>();
|
||||||
|
|
||||||
var articles = await dbService.GetFilteredNewsAsync(limit, offset, isin, date, status, searchQuery);
|
var articles = await dbService.GetFilteredNewsAsync(limit, offset, isin, date, status, searchQuery);
|
||||||
var dtos = (await Task.WhenAll(articles.Select(a => MapToDtoAsync(a)))).ToList();
|
var dtos = (await Task.WhenAll(articles.Select(a => MapToDtoAsync(a)))).ToList();
|
||||||
|
|
||||||
string responseTopic = $"services/response/news_Get/{correlationId}";
|
string responseTopic = $"services/response/news_Get/{correlationId}";
|
||||||
_logger.LogInformation("Publishing RPC response to {ResponseTopic} with {Count} articles.", responseTopic,
|
await finlyticLogger.LogInfoAsync(SettingKeys.NewsChannel, "Publishing RPC response to {ResponseTopic} with {Count} articles.", responseTopic, dtos.Count);
|
||||||
dtos.Count);
|
|
||||||
await PublishAsync(responseTopic, dtos);
|
await PublishAsync(responseTopic, dtos);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to compile RPC response for news_Get");
|
await finlyticLogger.LogErrorAsync(SettingKeys.NewsChannel, ex, "Failed to compile RPC response for news_Get");
|
||||||
|
|
||||||
// Antworte mit leerer Liste, um RPC-Timeouts im Gateway zu vermeiden
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string responseTopic = $"services/response/news_Get/{correlationId}";
|
string responseTopic = $"services/response/news_Get/{correlationId}";
|
||||||
await PublishAsync(responseTopic, new List<NewsArticleDto>());
|
await PublishAsync(responseTopic, new List<NewsArticleDto>());
|
||||||
}
|
}
|
||||||
catch
|
catch { }
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnGetNewsByIdAsync(string payload, string correlationId)
|
private async Task OnGetNewsByIdAsync(string payload, string correlationId)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Received RPC news_GetById request. Correlation: {CorrelationId}", correlationId);
|
|
||||||
string responseTopic = $"services/response/news_GetById/{correlationId}";
|
string responseTopic = $"services/response/news_GetById/{correlationId}";
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(payload))
|
if (string.IsNullOrWhiteSpace(payload))
|
||||||
{
|
{
|
||||||
await PublishAsync(responseTopic, (object?)null);
|
await PublishAsync(responseTopic, (object?)null);
|
||||||
@@ -222,9 +231,7 @@ public class NewsMqttClient : ManagedMqttClient, IHostedService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var request =
|
var request = JsonSerializer.Deserialize<ArticleRequest>(payload, FinlyticJsonSerializerContext.Default.ArticleRequest);
|
||||||
JsonSerializer.Deserialize<ArticleRequest>(payload,
|
|
||||||
FinlyticJsonSerializerContext.Default.ArticleRequest);
|
|
||||||
var targetIdStr = request?.ArticleId ?? request?.Id;
|
var targetIdStr = request?.ArticleId ?? request?.Id;
|
||||||
|
|
||||||
if (Guid.TryParse(targetIdStr, out var articleId))
|
if (Guid.TryParse(targetIdStr, out var articleId))
|
||||||
@@ -241,33 +248,25 @@ public class NewsMqttClient : ManagedMqttClient, IHostedService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch { }
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to execute RPC news_GetById");
|
|
||||||
}
|
|
||||||
|
|
||||||
await PublishAsync(responseTopic, (object?)null);
|
await PublishAsync(responseTopic, (object?)null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnGetPendingNewsAsync(string payload, string correlationId)
|
private async Task OnGetPendingNewsAsync(string payload, string correlationId)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Received RPC news_GetPending request. Correlation: {CorrelationId}", correlationId);
|
|
||||||
int limit = 10;
|
int limit = 10;
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(payload))
|
if (!string.IsNullOrWhiteSpace(payload))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var doc = JsonDocument.Parse(payload);
|
using var doc = JsonDocument.Parse(payload);
|
||||||
if (doc.RootElement.TryGetProperty("limit", out var limitProp) &&
|
if (doc.RootElement.TryGetProperty("limit", out var limitProp) && limitProp.TryGetInt32(out var parsedLimit))
|
||||||
limitProp.TryGetInt32(out var parsedLimit))
|
|
||||||
{
|
{
|
||||||
limit = Math.Min(parsedLimit, 10);
|
limit = Math.Min(parsedLimit, 10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch { }
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -279,25 +278,17 @@ public class NewsMqttClient : ManagedMqttClient, IHostedService
|
|||||||
var dtos = (await Task.WhenAll(pendingArticles.Take(limit).Select(a => MapToDtoAsync(a)))).ToList();
|
var dtos = (await Task.WhenAll(pendingArticles.Take(limit).Select(a => MapToDtoAsync(a)))).ToList();
|
||||||
|
|
||||||
string responseTopic = $"services/response/news_GetPending/{correlationId}";
|
string responseTopic = $"services/response/news_GetPending/{correlationId}";
|
||||||
_logger.LogInformation("Publishing RPC news_GetPending response to {ResponseTopic} with {Count} articles.",
|
|
||||||
responseTopic, dtos.Count);
|
|
||||||
await PublishAsync(responseTopic, dtos);
|
await PublishAsync(responseTopic, dtos);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch { }
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to publish RPC pending news response");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnUpdateNewsStatusAsync(string payload, string correlationId)
|
private async Task OnUpdateNewsStatusAsync(string payload, string correlationId)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Received RPC news_UpdateStatus request. Correlation: {CorrelationId}", correlationId);
|
|
||||||
UpdateNewsStatusResponse response;
|
UpdateNewsStatusResponse response;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var request = JsonSerializer.Deserialize<UpdateNewsStatusRequest>(payload,
|
var request = JsonSerializer.Deserialize<UpdateNewsStatusRequest>(payload, FinlyticJsonSerializerContext.Default.UpdateNewsStatusRequest);
|
||||||
FinlyticJsonSerializerContext.Default.UpdateNewsStatusRequest);
|
|
||||||
if (request != null && request.Id != Guid.Empty)
|
if (request != null && request.Id != Guid.Empty)
|
||||||
{
|
{
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
@@ -313,7 +304,6 @@ public class NewsMqttClient : ManagedMqttClient, IHostedService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to execute RPC news_UpdateStatus");
|
|
||||||
response = new UpdateNewsStatusResponse(false, ex.Message);
|
response = new UpdateNewsStatusResponse(false, ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,42 +311,99 @@ public class NewsMqttClient : ManagedMqttClient, IHostedService
|
|||||||
await PublishAsync(responseTopic, response);
|
await PublishAsync(responseTopic, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task OnSettingsGetAllAsync(string correlationId)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<NewsMqttClient>>();
|
||||||
|
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||||
|
|
||||||
|
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticNews] [Settings_GetAll] Retrieving all dynamic settings via reflection [CorrelationId: {CorrelationId}]", correlationId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var settings = await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) });
|
||||||
|
var responseTopic = $"services/response/news_settings_GetAll/{correlationId}";
|
||||||
|
|
||||||
|
await PublishAsync(responseTopic, settings);
|
||||||
|
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticNews] [Settings_GetAll] Published {Count} settings to '{ResponseTopic}'", settings.Count, responseTopic);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await finlyticLogger.LogErrorAsync(SettingKeys.MqttChannel, ex, "[FinlyticNews] [Settings_GetAll] Failed to retrieve settings.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnSettingsUpdateAsync(string payload, string correlationId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(payload)) return;
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<NewsMqttClient>>();
|
||||||
|
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||||
|
|
||||||
|
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticNews] [Settings_Update] Processing settings update RPC [CorrelationId: {CorrelationId}]", correlationId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Dictionary<string, object?>? updates = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
updates = JsonSerializer.Deserialize<Dictionary<string, object?>>(payload);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
var list = JsonSerializer.Deserialize<List<DynamicSettingDto>>(payload);
|
||||||
|
if (list != null)
|
||||||
|
{
|
||||||
|
updates = new Dictionary<string, object?>();
|
||||||
|
foreach (var item in list) updates[item.Key] = item.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates != null && updates.Count > 0)
|
||||||
|
{
|
||||||
|
await settingsService.UpdateSettingsAsync(updates);
|
||||||
|
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticNews] [Settings_Update] Successfully updated {Count} settings in database and cache.", updates.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentSettings = await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) });
|
||||||
|
var responseTopic = $"services/response/news_settings_Update/{correlationId}";
|
||||||
|
await PublishAsync(responseTopic, currentSettings);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await finlyticLogger.LogErrorAsync(SettingKeys.MqttChannel, ex, "[FinlyticNews] [Settings_Update] Failed to update settings.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task OnConfigUpdatedAsync(string payload)
|
private async Task OnConfigUpdatedAsync(string payload)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Received config update event for FinlyticNews.");
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var doc = JsonDocument.Parse(payload);
|
using var doc = JsonDocument.Parse(payload);
|
||||||
if (doc.RootElement.TryGetProperty("settings", out var settingsProp))
|
if (doc.RootElement.TryGetProperty("settings", out var settingsProp))
|
||||||
{
|
{
|
||||||
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(settingsProp.GetRawText());
|
var dict = JsonSerializer.Deserialize<Dictionary<string, object?>>(settingsProp.GetRawText());
|
||||||
if (dict != null && dict.Count > 0)
|
if (dict != null && dict.Count > 0)
|
||||||
{
|
{
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var settingsDb = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
|
var settings = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||||
await settingsDb.UpdateSettingsFromDictionaryAsync(dict);
|
await settings.UpdateSettingsAsync(dict);
|
||||||
_logger.LogInformation("Successfully persisted {Count} updated settings.", dict.Count);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch { }
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error processing MQTT config update event");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnHealthPingAsync(string[] segments, string correlationId)
|
private async Task OnHealthPingAsync(string[] segments, string correlationId)
|
||||||
{
|
{
|
||||||
// Zerlegt den Topic-Pfad z. B. services/request/health_Ping/FinlyticNews/{correlationId}
|
|
||||||
bool isForMe = segments.Length >= 5 && segments[3].Equals("FinlyticNews", StringComparison.OrdinalIgnoreCase);
|
bool isForMe = segments.Length >= 5 && segments[3].Equals("FinlyticNews", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
if (isForMe)
|
if (isForMe)
|
||||||
{
|
{
|
||||||
string respTopic = $"services/response/health_Ping/{correlationId}";
|
string respTopic = $"services/response/health_Ping/{correlationId}";
|
||||||
await PublishAsync(respTopic,
|
await PublishAsync(respTopic, new ServiceHealthResponse("FinlyticNews", "Online", DateTime.UtcNow, "Connected"));
|
||||||
new ServiceHealthResponse("FinlyticNews", "Online", DateTime.UtcNow, "Connected"));
|
|
||||||
_logger.LogInformation("Responded to live health_Ping RPC request [CorrelationId: {CorrelationId}].",
|
using var scope = _scopeFactory.CreateScope();
|
||||||
correlationId);
|
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<NewsMqttClient>>();
|
||||||
|
await finlyticLogger.LogInfoAsync(SettingKeys.HealthPingChannel, "Responded to live health_Ping RPC request [CorrelationId: {CorrelationId}].", correlationId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,41 +422,31 @@ public class NewsMqttClient : ManagedMqttClient, IHostedService
|
|||||||
var targetId = a.Id.ToString();
|
var targetId = a.Id.ToString();
|
||||||
IsinAnalysisEntry? sentimentEntry = null;
|
IsinAnalysisEntry? sentimentEntry = null;
|
||||||
|
|
||||||
// 1. Snappy Local Disk Check for Article File
|
var articlePath = Path.Combine(Directory.GetCurrentDirectory(), "data", "summaries", "articles", $"{targetId}.json");
|
||||||
var articlePath = Path.Combine(Directory.GetCurrentDirectory(), "data", "summaries", "articles",
|
|
||||||
$"{targetId}.json");
|
|
||||||
if (File.Exists(articlePath))
|
if (File.Exists(articlePath))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var json = await File.ReadAllTextAsync(articlePath);
|
var json = await File.ReadAllTextAsync(articlePath);
|
||||||
sentimentEntry = JsonSerializer.Deserialize<IsinAnalysisEntry>(json,
|
sentimentEntry = JsonSerializer.Deserialize<IsinAnalysisEntry>(json, FinlyticJsonSerializerContext.Default.IsinAnalysisEntry);
|
||||||
FinlyticJsonSerializerContext.Default.IsinAnalysisEntry);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
}
|
}
|
||||||
|
catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. ISIN Summary File Fallback
|
|
||||||
if (sentimentEntry == null && a.MatchedAssets != null && a.MatchedAssets.Count > 0)
|
if (sentimentEntry == null && a.MatchedAssets != null && a.MatchedAssets.Count > 0)
|
||||||
{
|
{
|
||||||
foreach (var asset in a.MatchedAssets)
|
foreach (var asset in a.MatchedAssets)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(asset.Isin)) continue;
|
if (string.IsNullOrWhiteSpace(asset.Isin)) continue;
|
||||||
var isinPath = Path.Combine(Directory.GetCurrentDirectory(), "data", "summaries", "isin",
|
var isinPath = Path.Combine(Directory.GetCurrentDirectory(), "data", "summaries", "isin", $"{asset.Isin.Trim()}.json");
|
||||||
$"{asset.Isin.Trim()}.json");
|
|
||||||
|
|
||||||
if (File.Exists(isinPath))
|
if (File.Exists(isinPath))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var json = await File.ReadAllTextAsync(isinPath);
|
var json = await File.ReadAllTextAsync(isinPath);
|
||||||
var isinDoc = JsonSerializer.Deserialize<IsinSentimentSummaryDto>(json,
|
var isinDoc = JsonSerializer.Deserialize<IsinSentimentSummaryDto>(json, FinlyticJsonSerializerContext.Default.IsinSentimentSummaryDto);
|
||||||
FinlyticJsonSerializerContext.Default.IsinSentimentSummaryDto);
|
var match = isinDoc?.Analyses?.FirstOrDefault(entry => string.Equals(entry.Article?.ArticleId?.Trim(), targetId, StringComparison.OrdinalIgnoreCase));
|
||||||
var match = isinDoc?.Analyses?.FirstOrDefault(entry =>
|
|
||||||
string.Equals(entry.Article?.ArticleId?.Trim(), targetId,
|
|
||||||
StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
if (match != null)
|
if (match != null)
|
||||||
{
|
{
|
||||||
@@ -417,9 +454,7 @@ public class NewsMqttClient : ManagedMqttClient, IHostedService
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch { }
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -432,10 +467,7 @@ public class NewsMqttClient : ManagedMqttClient, IHostedService
|
|||||||
confidence = finbertResult.Confidence;
|
confidence = finbertResult.Confidence;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch { }
|
||||||
{
|
|
||||||
_logger.LogTrace(ex, "[MapToDtoAsync] Sentiment fetch skipped for article {Id}", a.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new NewsArticleDto
|
return new NewsArticleDto
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using FinlyticCore.Models.Settings;
|
||||||
|
|
||||||
|
namespace FinlyticNews.Util;
|
||||||
|
|
||||||
|
public static class SettingKeys
|
||||||
|
{
|
||||||
|
// --- Logging-Kanäle ---
|
||||||
|
public static readonly SettingKey<bool> NewsChannel = new("Logging.Channel.News", true);
|
||||||
|
public static readonly SettingKey<bool> MqttChannel = new("Logging.Channel.MQTT", true);
|
||||||
|
public static readonly SettingKey<bool> HealthPingChannel = new("Logging.Channel.Health", true);
|
||||||
|
|
||||||
|
// --- Scraping & Feed-Konfiguration ---
|
||||||
|
public static readonly SettingKey<int> ScrapeIntervalMinutes = new("Scraping.IntervalMinutes", 15);
|
||||||
|
public static readonly SettingKey<int> MaxArticlesPerFeed = new("Scraping.MaxArticlesPerFeed", 20);
|
||||||
|
public static readonly SettingKey<bool> EnableAutoScraping = new("Feature.EnableAutoScraping", true);
|
||||||
|
public static readonly SettingKey<int> HttpTimeoutSeconds = new("Scraping.HttpTimeoutSeconds", 20);
|
||||||
|
|
||||||
|
// --- KI & Sentiment-Konfiguration ---
|
||||||
|
public static readonly SettingKey<int> FinBertBatchSize = new("AI.FinBertBatchSize", 8);
|
||||||
|
public static readonly SettingKey<double> MinSentimentConfidence = new("AI.MinSentimentConfidence", 0.65);
|
||||||
|
|
||||||
|
// --- Daten-Retention & Cleanup ---
|
||||||
|
public static readonly SettingKey<int> ArticleRetentionDays = new("Data.ArticleRetentionDays", 90);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user