using System; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.Playwright; namespace FinlyticNews.Adapters.Scraping; /// /// Record holding structured article content extracted from a news webpage. /// public record ScrapedArticleResult( string Title, string TextContent, string HtmlContent, string? Author, string? Excerpt, string FinalUrl, DateTime? PublishedAt = null, string? Language = null ); /// /// Abstract base class representing a website-specific scraping adapter with heuristic extraction and DOM cleanup. /// public abstract class ArticleScraperAdapter { /// /// Gets the target domain hostname of the news website (e.g. "finanznachrichten.de"). /// public abstract string Hostname { get; } /// /// Gets the CSS selector to identify and click the 'Read full article' redirect button, if applicable. /// public virtual string? ReadMoreSelector => null; /// /// Fallback CSS selector targeting the article body text elements if Readability/JSON-LD fails. /// public virtual string ArticleBodySelector => "article, main, div.article-content, div.article-text, div.entry-content, body"; private static readonly Regex RegulatoryDisclaimerRegex = new( @"(Interessenkonflikt[e:]|Offenlegung nach §|Risikohinweis:|Disclaimer:|Hinweis auf Interessenkonflikte|Keine Anlageberatung).*$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline); 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); /// /// Tries to resolve 'Read More' links or external redirects. /// public virtual async Task TryResolveRedirectUrlAsync(IPage page) { var selector = ReadMoreSelector; if (string.IsNullOrWhiteSpace(selector)) return null; var readMoreButton = page.Locator(selector).First; if (await readMoreButton.CountAsync() > 0 && await readMoreButton.IsVisibleAsync()) { try { var popupTask = page.Context.WaitForPageAsync(new() { Timeout = 6000 }); var navTask = page.WaitForURLAsync(url => url != page.Url, new() { Timeout = 6000 }); await readMoreButton.ClickAsync(); var completedTask = await Task.WhenAny(popupTask, navTask); if (completedTask == popupTask) { var popup = await popupTask; await popup.WaitForLoadStateAsync(LoadState.DOMContentLoaded); return popup.Url; } await navTask; return page.Url; } catch { // Fallback to current URL if click/navigation times out } } return null; } /// /// Extracts structured article content using JSON-LD metadata, DOM noise pruning, and Mozilla Readability. /// public virtual async Task ExtractArticleContentAsync(IPage page) { try { // 1. Remove DOM noise and ads before extraction await page.EvaluateAsync(@"() => { const noiseSelectors = [ 'nav', 'header', 'footer', 'aside', '.cookie-banner', '#consent', '[id*=""ad-""], [class*=""banner""], [class*=""outbrain""], [class*=""taboola""]', '[class*=""share-buttons""], .social-media, .related-articles, .comments', '#forum_box', '.newsletter-box', '.audio-player', '.tags-container' ]; for (const sel of noiseSelectors) { document.querySelectorAll(sel).forEach(el => el.remove()); } }"); // 2. Extract JSON-LD (Schema.org) if available var jsonLdResult = await page.EvaluateAsync(@"() => { const scripts = document.querySelectorAll('script[type=""application/ld+json""]'); for (const script of scripts) { try { const data = JSON.parse(script.textContent); const items = Array.isArray(data) ? data : (data['@graph'] || [data]); for (const item of items) { if (item['@type'] === 'NewsArticle' || item['@type'] === 'Article' || item['@type'] === 'Report') { return { headline: item.headline || null, description: item.description || null, articleBody: item.articleBody || null, author: typeof item.author === 'object' ? item.author.name : item.author, datePublished: item.datePublished || item.dateCreated || null }; } } } catch (e) {} } return null; }"); string? title = null; string? excerpt = null; string? author = null; DateTime? publishedAt = null; if (jsonLdResult.HasValue && jsonLdResult.Value.ValueKind != JsonValueKind.Null) { var root = jsonLdResult.Value; if (root.TryGetProperty("headline", out var hl) && hl.ValueKind == JsonValueKind.String) { title = CleanTitle(hl.GetString()); } if (root.TryGetProperty("description", out var desc) && desc.ValueKind == JsonValueKind.String) { excerpt = desc.GetString(); } if (root.TryGetProperty("author", out var auth) && auth.ValueKind == JsonValueKind.String) { author = auth.GetString(); } if (root.TryGetProperty("datePublished", out var dp) && dp.ValueKind == JsonValueKind.String) { if (DateTime.TryParse(dp.GetString(), out var dt)) publishedAt = dt; } } // 3. Fallback to OpenGraph and Meta tags for missing metadata if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(excerpt)) { var metaResult = await page.EvaluateAsync(@"() => { const getMeta = (prop) => { const el = document.querySelector(`meta[property=""${prop}""], meta[name=""${prop}""]`); return el ? el.getAttribute('content') : null; }; return { ogTitle: getMeta('og:title') || getMeta('twitter:title'), ogDesc: getMeta('og:description') || getMeta('description') || getMeta('twitter:description'), author: getMeta('author') || getMeta('article:author'), pubDate: getMeta('article:published_time') || getMeta('date') }; }"); if (metaResult.HasValue && metaResult.Value.ValueKind != JsonValueKind.Null) { var m = metaResult.Value; if (string.IsNullOrWhiteSpace(title) && m.TryGetProperty("ogTitle", out var ogT) && ogT.ValueKind == JsonValueKind.String) { title = CleanTitle(ogT.GetString()); } if (string.IsNullOrWhiteSpace(excerpt) && m.TryGetProperty("ogDesc", out var ogD) && ogD.ValueKind == JsonValueKind.String) { excerpt = ogD.GetString(); } if (string.IsNullOrWhiteSpace(author) && m.TryGetProperty("author", out var ogA) && ogA.ValueKind == JsonValueKind.String) { author = ogA.GetString(); } if (!publishedAt.HasValue && m.TryGetProperty("pubDate", out var ogP) && ogP.ValueKind == JsonValueKind.String) { if (DateTime.TryParse(ogP.GetString(), out var dt)) publishedAt = dt; } } } // 4. Inject Mozilla Readability to extract main content await page.AddScriptTagAsync(new PageAddScriptTagOptions { Url = "https://cdn.jsdelivr.net/npm/@mozilla/readability@0.5.0/Readability.min.js" }); var readabilityResult = await page.EvaluateAsync(@"() => { if (typeof Readability === 'undefined') return null; const documentClone = document.cloneNode(true); const article = new Readability(documentClone).parse(); if (!article) return null; return { title: article.title || '', textContent: article.textContent || '', htmlContent: article.content || '', author: article.byline || null, excerpt: article.excerpt || null }; }"); if (readabilityResult.HasValue && readabilityResult.Value.ValueKind != JsonValueKind.Null) { var r = readabilityResult.Value; var rawText = r.GetProperty("textContent").GetString()?.Trim() ?? string.Empty; var cleanedText = TruncateDisclaimers(rawText); if (string.IsNullOrWhiteSpace(title)) { title = CleanTitle(r.GetProperty("title").GetString() ?? await page.TitleAsync()); } return new ScrapedArticleResult( Title: title ?? string.Empty, TextContent: cleanedText, HtmlContent: r.GetProperty("htmlContent").GetString() ?? string.Empty, Author: author ?? r.GetProperty("author").GetString(), Excerpt: excerpt ?? r.GetProperty("excerpt").GetString(), FinalUrl: page.Url, PublishedAt: publishedAt ); } } catch { // Readability/Script injection failed -> Fallback to selector parsing } // 5. Selector-based fallback extraction var bodyLocator = page.Locator(ArticleBodySelector).First; var bodyText = await bodyLocator.CountAsync() > 0 ? await bodyLocator.InnerTextAsync() : await page.InnerTextAsync("body"); var fallbackCleaned = TruncateDisclaimers(bodyText.Trim()); return new ScrapedArticleResult( Title: CleanTitle(await page.TitleAsync()), TextContent: fallbackCleaned, HtmlContent: string.Empty, Author: null, Excerpt: null, FinalUrl: page.Url ); } protected static string CleanTitle(string? title) { if (string.IsNullOrWhiteSpace(title)) return string.Empty; var cleaned = TitlePortalSuffixRegex.Replace(title.Trim(), ""); return cleaned.Trim(); } protected static string TruncateDisclaimers(string text) { if (string.IsNullOrWhiteSpace(text)) return string.Empty; var truncated = RegulatoryDisclaimerRegex.Replace(text, ""); return truncated.Trim(); } }