using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Numerics; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Services; using FinlyticNews.Database; using FinlyticNews.Util; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace FinlyticNews.Services; /// /// Result of a duplicate check on a candidate news article. /// public record DuplicateCheckResult( bool IsDuplicate, Guid? DuplicateOfArticleId, string? Reason, string TitleHash, long SimHash ); /// /// Service interface for detecting syndicated or near-identical duplicate news articles without AI. /// public interface IArticleDeduplicationService { /// /// Evaluates whether an article is a duplicate based on normalized title similarity and 64-bit text SimHash fingerprinting. /// DuplicateCheckResult CheckDuplicate(string title, string content, DateTime publishedAt); /// /// Registers a newly processed article into the in-memory deduplication cache. /// void RegisterArticle(Guid articleId, string titleHash, long simHash, string title, DateTime publishedAt); /// /// Loads recent articles from the database into the deduplication cache on startup. /// Task InitializeAsync(CancellationToken cancellationToken = default); /// /// Computes the normalized SHA-256 hash of an article title. /// string ComputeTitleHash(string title); /// /// Computes the 64-bit SimHash content fingerprint of article text. /// long ComputeSimHash(string content); } /// /// High-performance non-AI deduplication engine using 64-bit SimHash and N-Gram title similarity. /// public class ArticleDeduplicationService : IArticleDeduplicationService { private record CachedArticleEntry( Guid ArticleId, string TitleHash, long SimHash, string NormalizedTitle, DateTime PublishedAt ); private readonly IServiceScopeFactory _scopeFactory; private readonly IFinlyticLogger _finlyticLogger; private readonly ISettingsService _settingsService; // Rolling in-memory cache of recent articles private readonly ConcurrentDictionary _titleHashIndex = new(StringComparer.OrdinalIgnoreCase); private readonly List _simHashIndex = []; private readonly ReaderWriterLockSlim _simHashLock = new(); private bool _initialized; private readonly SemaphoreSlim _initLock = new(1, 1); private static readonly Regex TitlePortalSuffixRegex = new( @"\s*[-|–—]\s*(DER AKTIONÄR|ARIVA\.DE|IT-Times|onvista|wallstreet:online|Sharedeals\.de|boerse\.de|Handelsblatt|WirtschaftsWoche|finanzen\.net|Finanznachrichten|XTB|Lynx|T3n|ntg24|Moneycab).*$", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex NonAlphanumericRegex = new(@"[^\w\s]", RegexOptions.Compiled); private static readonly Regex MultipleSpacesRegex = new(@"\s+", RegexOptions.Compiled); public ArticleDeduplicationService( IServiceScopeFactory scopeFactory, IFinlyticLogger finlyticLogger, ISettingsService settingsService) { _scopeFactory = scopeFactory; _finlyticLogger = finlyticLogger; _settingsService = settingsService; } public async Task InitializeAsync(CancellationToken cancellationToken = default) { if (_initialized) return; await _initLock.WaitAsync(cancellationToken); try { if (_initialized) return; using var scope = _scopeFactory.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); var windowDays = await _settingsService.GetSettingAsync(SettingKeys.DeduplicationWindowDays, cancellationToken); var cutoff = DateTime.UtcNow.AddDays(-Math.Max(windowDays, 7)); var articles = await dbContext.NewsArticles .AsNoTracking() .Where(a => a.PublishedAt >= cutoff && a.Status == "Completed") .Select(a => new { a.Id, a.Title, a.TitleHash, a.SimHash, a.PublishedAt }) .ToListAsync(cancellationToken); _simHashLock.EnterWriteLock(); try { foreach (var a in articles) { var tHash = a.TitleHash ?? ComputeTitleHash(a.Title); var sHash = a.SimHash ?? 0L; var normTitle = NormalizeTitle(a.Title); _titleHashIndex[tHash] = a.Id; if (sHash != 0L) { _simHashIndex.Add(new CachedArticleEntry(a.Id, tHash, sHash, normTitle, a.PublishedAt)); } } } finally { _simHashLock.ExitWriteLock(); } _initialized = true; await _finlyticLogger.LogInfoAsync(SettingKeys.DeduplicationChannel, "[ArticleDeduplicationService] Initialized with {Count} recent articles for duplicate detection.", articles.Count); } finally { _initLock.Release(); } } public DuplicateCheckResult CheckDuplicate(string title, string content, DateTime publishedAt) { var titleHash = ComputeTitleHash(title); var simHash = ComputeSimHash(content); var normalizedTitle = NormalizeTitle(title); // 1. Exact Title Hash Match if (_titleHashIndex.TryGetValue(titleHash, out var exactMatchId)) { return new DuplicateCheckResult( IsDuplicate: true, DuplicateOfArticleId: exactMatchId, Reason: "ExactTitleHashMatch", TitleHash: titleHash, SimHash: simHash ); } _simHashLock.EnterReadLock(); try { var windowCutoff = publishedAt.AddDays(-7); foreach (var entry in _simHashIndex) { if (entry.PublishedAt < windowCutoff) continue; // 2. SimHash Content Similarity (Hamming Distance <= 3 bits -> > 90% identical text) if (simHash != 0L && entry.SimHash != 0L) { int distance = HammingDistance(simHash, entry.SimHash); if (distance <= 3) { return new DuplicateCheckResult( IsDuplicate: true, DuplicateOfArticleId: entry.ArticleId, Reason: $"SimHashSimilarity (HammingDistance: {distance})", TitleHash: titleHash, SimHash: simHash ); } } // 3. Fuzzy Title Similarity (3-gram Jaccard Index >= 0.85) double titleSim = Calculate3GramJaccard(normalizedTitle, entry.NormalizedTitle); if (titleSim >= 0.85) { return new DuplicateCheckResult( IsDuplicate: true, DuplicateOfArticleId: entry.ArticleId, Reason: $"FuzzyTitleSimilarity ({titleSim:P0})", TitleHash: titleHash, SimHash: simHash ); } } } finally { _simHashLock.ExitReadLock(); } return new DuplicateCheckResult( IsDuplicate: false, DuplicateOfArticleId: null, Reason: null, TitleHash: titleHash, SimHash: simHash ); } public void RegisterArticle(Guid articleId, string titleHash, long simHash, string title, DateTime publishedAt) { _titleHashIndex[titleHash] = articleId; _simHashLock.EnterWriteLock(); try { _simHashIndex.Add(new CachedArticleEntry(articleId, titleHash, simHash, NormalizeTitle(title), publishedAt)); } finally { _simHashLock.ExitWriteLock(); } } public string ComputeTitleHash(string title) { var normalized = NormalizeTitle(title); var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(normalized)); return Convert.ToHexString(bytes).ToLowerInvariant(); } public long ComputeSimHash(string content) { if (string.IsNullOrWhiteSpace(content)) return 0L; // Clean and tokenize content into words var clean = NonAlphanumericRegex.Replace(content.ToLowerInvariant(), " "); var words = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (words.Length < 10) return 0L; // Generate 3-word shingles var v = new int[64]; for (int i = 0; i < words.Length - 2; i++) { var shingle = $"{words[i]} {words[i + 1]} {words[i + 2]}"; var hash = Hash64(shingle); for (int bit = 0; bit < 64; bit++) { if (((hash >> bit) & 1L) == 1L) { v[bit]++; } else { v[bit]--; } } } long simHash = 0L; for (int bit = 0; bit < 64; bit++) { if (v[bit] > 0) { simHash |= (1L << bit); } } return simHash; } private static string NormalizeTitle(string title) { if (string.IsNullOrWhiteSpace(title)) return string.Empty; var stripped = TitlePortalSuffixRegex.Replace(title, ""); var cleaned = NonAlphanumericRegex.Replace(stripped.ToLowerInvariant(), " "); return MultipleSpacesRegex.Replace(cleaned, " ").Trim(); } private static double Calculate3GramJaccard(string a, string b) { if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return 0.0; if (a == b) return 1.0; var gramsA = Get3Grams(a); var gramsB = Get3Grams(b); if (gramsA.Count == 0 || gramsB.Count == 0) return 0.0; int intersection = 0; foreach (var g in gramsA) { if (gramsB.Contains(g)) intersection++; } int union = gramsA.Count + gramsB.Count - intersection; return union == 0 ? 0.0 : (double)intersection / union; } private static HashSet Get3Grams(string text) { var set = new HashSet(StringComparer.Ordinal); if (text.Length < 3) { set.Add(text); return set; } for (int i = 0; i <= text.Length - 3; i++) { set.Add(text.Substring(i, 3)); } return set; } private static int HammingDistance(long a, long b) { return BitOperations.PopCount((ulong)(a ^ b)); } private static long Hash64(string text) { // 64-bit FNV-1a Hash ulong hash = 14695981039346656037UL; var bytes = Encoding.UTF8.GetBytes(text); foreach (var b in bytes) { hash ^= b; hash *= 1099511628211UL; } return (long)hash; } }