using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Dtos.Yahoo; using FinlyticCore.Models.Settings; using FinlyticCore.Services; using FinlyticCore.Services.PlaywrightScrapper; using Microsoft.Playwright; namespace FinlyticCore.Services.Yahoo; public class YahooFinanceClient { private readonly HttpClient _httpClient; private readonly CookieContainer _cookieContainer; private readonly IFinlyticLogger? _finlyticLogger; private readonly ISettingsService? _settingsService; private readonly IPlaywrightExecutionService? _playwrightService; private readonly SemaphoreSlim _authLock = new(1, 1); private string? _crumb; private string? _rawCookieHeader; private DateTime _lastAuthTime = DateTime.MinValue; /// /// Standard modules available for the quoteSummary endpoint. /// public static readonly string[] StandardQuoteSummaryModules = new[] { "assetProfile", "financialData", "defaultKeyStatistics", "summaryDetail", "incomeStatementHistory", "incomeStatementHistoryQuarterly", "balanceSheetHistory", "balanceSheetHistoryQuarterly", "cashflowStatementHistory", "cashflowStatementHistoryQuarterly", "calendarEvents" }; public YahooFinanceClient( IFinlyticLogger? finlyticLogger = null, ISettingsService? settingsService = null, IPlaywrightExecutionService? playwrightService = null, HttpClient? httpClient = null) { _finlyticLogger = finlyticLogger; _settingsService = settingsService; _playwrightService = playwrightService; _cookieContainer = new CookieContainer(); if (httpClient != null) { _httpClient = httpClient; } else { var handler = new HttpClientHandler { CookieContainer = _cookieContainer, UseCookies = true, AllowAutoRedirect = true }; _httpClient = new HttpClient(handler); _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"); } } /// /// Ensures that an active Yahoo session (Cookie + dynamic Crumb token) is initialized. /// Uses persistent DB caching and only refreshes when the crumb is invalid or forceRefresh is true. /// public async Task EnsureAuthenticatedAsync(bool forceRefresh = false, CancellationToken cancellationToken = default) { await _authLock.WaitAsync(cancellationToken); try { // 1. Check in-memory crumb if (!forceRefresh && !string.IsNullOrWhiteSpace(_crumb)) { return _crumb; } // 2. Check persistent DB cache via SettingsService if (!forceRefresh && _settingsService != null) { try { var cachedCrumb = await _settingsService.GetSettingAsync(CoreSettingKeys.YahooAuthCrumb, cancellationToken); var cachedCookies = await _settingsService.GetSettingAsync(CoreSettingKeys.YahooAuthCookie, cancellationToken); if (!string.IsNullOrWhiteSpace(cachedCrumb) && !string.IsNullOrWhiteSpace(cachedCookies)) { RestoreCookies(cachedCookies); _crumb = cachedCrumb; _rawCookieHeader = cachedCookies; _lastAuthTime = DateTime.UtcNow; if (_finlyticLogger != null) await _finlyticLogger.LogInfoAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] Restored cached Yahoo session & crumb from database ({Crumb}).", _crumb); return _crumb; } } catch (Exception ex) { if (_finlyticLogger != null) await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, ex, "[YahooFinanceClient] Error restoring cached Yahoo session from DB."); } } if (_finlyticLogger != null) await _finlyticLogger.LogInfoAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] Authenticating fresh session with Yahoo (Cookie + Crumb)..."); // 3. Send GET request to fc.yahoo.com to obtain session cookie A3 try { using var initRequest = new HttpRequestMessage(HttpMethod.Get, "https://fc.yahoo.com"); initRequest.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"); initRequest.Headers.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"); using var initResponse = await _httpClient.SendAsync(initRequest, cancellationToken); } catch { } // 4. Send GET request to getcrumb to obtain dynamic crumb token var crumb = await FetchCrumbWithHttpClientAsync(cancellationToken); if (!string.IsNullOrWhiteSpace(crumb)) { _crumb = crumb; _lastAuthTime = DateTime.UtcNow; await PersistSessionAsync(_crumb, cancellationToken); return _crumb; } // 5. FALLBACK: Playwright Browser Authentication (Bypasses EU Consent Wall and 429) if (_playwrightService != null) { var (browserCrumb, browserCookies) = await AuthenticateViaPlaywrightAsync(cancellationToken); if (!string.IsNullOrWhiteSpace(browserCrumb)) { _crumb = browserCrumb; _lastAuthTime = DateTime.UtcNow; if (!string.IsNullOrWhiteSpace(browserCookies)) { RestoreCookies(browserCookies); _rawCookieHeader = browserCookies; } await PersistSessionAsync(_crumb, cancellationToken); return _crumb; } } if (_finlyticLogger != null) await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] Failed to fetch crumb token from all endpoints."); return null; } catch (Exception ex) { if (_finlyticLogger != null) await _finlyticLogger.LogErrorAsync(CoreSettingKeys.YahooClientChannel, ex, "[YahooFinanceClient] Exception during Cookie & Crumb authentication."); return null; } finally { _authLock.Release(); } } private async Task FetchCrumbWithHttpClientAsync(CancellationToken cancellationToken) { string[] crumbUrls = new[] { "https://query1.finance.yahoo.com/v1/test/getcrumb", "https://query2.finance.yahoo.com/v1/test/getcrumb" }; foreach (var url in crumbUrls) { try { using var crumbRequest = new HttpRequestMessage(HttpMethod.Get, url); crumbRequest.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"); crumbRequest.Headers.Add("Accept", "*/*"); crumbRequest.Headers.Add("Origin", "https://finance.yahoo.com"); crumbRequest.Headers.Add("Referer", "https://finance.yahoo.com/"); if (!string.IsNullOrWhiteSpace(_rawCookieHeader)) { crumbRequest.Headers.Add("Cookie", _rawCookieHeader); } using var crumbResponse = await _httpClient.SendAsync(crumbRequest, cancellationToken); var crumbText = await crumbResponse.Content.ReadAsStringAsync(cancellationToken); if (crumbResponse.IsSuccessStatusCode && !string.IsNullOrWhiteSpace(crumbText)) { var cleanCrumb = crumbText.Trim('"', ' ', '\t', '\r', '\n'); if (!cleanCrumb.Contains(" AuthenticateViaPlaywrightAsync(CancellationToken cancellationToken) { if (_playwrightService == null) return (null, null); try { if (_finlyticLogger != null) await _finlyticLogger.LogInfoAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] Launching Playwright browser to acquire valid EU Yahoo session and Crumb..."); return await _playwrightService.ExecuteInContextAsync(async context => { var page = await context.NewPageAsync(); try { await page.GotoAsync("https://finance.yahoo.com/quote/AAPL/", new PageGotoOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 30_000 }); // Handle EU Consent if redirected var url = page.Url; if (url.Contains("consent.yahoo.com", StringComparison.OrdinalIgnoreCase) || url.Contains("guce.yahoo.com", StringComparison.OrdinalIgnoreCase)) { var selectors = new[] { "button[name='agree']", "button[value='agree']", "button.accept-all", "button.btn.primary", "button.btn.secondary.accept-all", "form[action*='consent'] button[type='submit']", "button:has-text('Alle akzeptieren')", "button:has-text('Accept all')", "button:has-text('Akzeptieren')", "button:has-text('Agree')" }; foreach (var sel in selectors) { var btn = page.Locator(sel); if (await btn.CountAsync() > 0 && await btn.First.IsVisibleAsync()) { await btn.First.ClickAsync(); await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded, new PageWaitForLoadStateOptions { Timeout = 15_000 }); break; } } } // 1. Extract cookies from BrowserContext var cookies = await context.CookiesAsync(); var pairs = new List(); foreach (var c in cookies) { if (c.Domain.Contains("yahoo.com", StringComparison.OrdinalIgnoreCase)) { pairs.Add($"{c.Name}={c.Value}"); } } var extractedCookies = string.Join(";", pairs); // 2. Fetch Crumb from inside the authenticated page string? extractedCrumb = null; try { extractedCrumb = await page.EvaluateAsync(@"async () => { try { const res = await fetch('/v1/test/getcrumb'); if (res.ok) { return await res.text(); } } catch {} return null; }"); } catch { } // 3. If in-page fetch was empty, use the extracted cookies with HttpClient if (string.IsNullOrWhiteSpace(extractedCrumb) && !string.IsNullOrWhiteSpace(extractedCookies)) { RestoreCookies(extractedCookies); _rawCookieHeader = extractedCookies; extractedCrumb = await FetchCrumbWithHttpClientAsync(cancellationToken); } if (!string.IsNullOrWhiteSpace(extractedCrumb)) { extractedCrumb = extractedCrumb.Trim('"', ' ', '\t', '\r', '\n'); if (_finlyticLogger != null) await _finlyticLogger.LogInfoAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] Playwright successfully acquired Yahoo Crumb token: {Crumb}", extractedCrumb); } return (extractedCrumb, extractedCookies); } finally { await page.CloseAsync(); } }, cancellationToken: cancellationToken); } catch (Exception ex) { if (_finlyticLogger != null) await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, ex, "[YahooFinanceClient] Playwright-based authentication failed."); return (null, null); } } private async Task PersistSessionAsync(string crumb, CancellationToken cancellationToken) { if (_settingsService == null || string.IsNullOrWhiteSpace(crumb)) return; try { var serializedCookies = SerializeCookies(); await _settingsService.SetSettingAsync(CoreSettingKeys.YahooAuthCrumb, crumb, cancellationToken); if (!string.IsNullOrWhiteSpace(serializedCookies)) { _rawCookieHeader = serializedCookies; await _settingsService.SetSettingAsync(CoreSettingKeys.YahooAuthCookie, serializedCookies, cancellationToken); } if (_finlyticLogger != null) await _finlyticLogger.LogInfoAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] Persisted valid Yahoo session & crumb to database."); } catch (Exception ex) { if (_finlyticLogger != null) await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, ex, "[YahooFinanceClient] Failed to persist Yahoo session to database."); } } private string SerializeCookies() { if (!string.IsNullOrWhiteSpace(_rawCookieHeader)) { return _rawCookieHeader; } try { var cookies = _cookieContainer.GetAllCookies(); var pairs = new List(); foreach (System.Net.Cookie cookie in cookies) { pairs.Add($"{cookie.Name}={cookie.Value}"); } return string.Join(";", pairs); } catch { return string.Empty; } } private void RestoreCookies(string serializedCookies) { if (string.IsNullOrWhiteSpace(serializedCookies)) return; _rawCookieHeader = serializedCookies; try { var parts = serializedCookies.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var uris = new[] { new Uri("https://yahoo.com"), new Uri("https://finance.yahoo.com"), new Uri("https://query1.finance.yahoo.com"), new Uri("https://query2.finance.yahoo.com"), new Uri("https://fc.yahoo.com") }; foreach (var part in parts) { var eqIdx = part.IndexOf('='); if (eqIdx > 0 && eqIdx < part.Length - 1) { var name = part.Substring(0, eqIdx).Trim(); var val = part.Substring(eqIdx + 1).Trim(); foreach (var uri in uris) { try { _cookieContainer.Add(uri, new System.Net.Cookie(name, val)); } catch { } } } } } catch { // Ignore cookie restore errors } } /// /// Searches for tickers, names, ISINs, or companies via the Yahoo Finance search API. /// public async Task SearchAsync( string query, int quotesCount = 10, int newsCount = 0, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(query)) return null; string[] endpoints = new[] { $"https://query2.finance.yahoo.com/v1/finance/search?q={Uri.EscapeDataString(query)}"esCount={quotesCount}&newsCount={newsCount}", $"https://query1.finance.yahoo.com/v1/finance/search?q={Uri.EscapeDataString(query)}"esCount={quotesCount}&newsCount={newsCount}" }; foreach (var url in endpoints) { try { using var request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"); request.Headers.Add("Accept", "*/*"); if (!string.IsNullOrWhiteSpace(_rawCookieHeader)) { request.Headers.Add("Cookie", _rawCookieHeader); } using var response = await _httpClient.SendAsync(request, cancellationToken); var json = await response.Content.ReadAsStringAsync(cancellationToken); if (response.IsSuccessStatusCode) { var dto = JsonSerializer.Deserialize(json, GetJsonOptions()); if (dto != null) { return dto; } } else { if (_finlyticLogger != null) await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] Search endpoint '{Url}' for '{Query}' returned status {Status}. Response Body: {Body}", url, query, response.StatusCode, json); } } catch (Exception ex) { if (_finlyticLogger != null) await _finlyticLogger.LogDebugAsync(CoreSettingKeys.YahooClientChannel, ex, "[YahooFinanceClient] Exception during Search endpoint '{Url}'", url); } } return null; } /// /// Retrieves fundamentals and company metadata using the quoteSummary endpoint. /// public async Task GetQuoteSummaryAsync( string symbol, IEnumerable modules, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(symbol)) return null; var moduleList = string.Join(",", modules); return await ExecuteWithRetryAsync(async (crumb) => { string[] baseUrls = new[] { "https://query2.finance.yahoo.com/v10/finance/quoteSummary", "https://query1.finance.yahoo.com/v10/finance/quoteSummary" }; foreach (var baseUrl in baseUrls) { var url = $"{baseUrl}/{Uri.EscapeDataString(symbol)}?crumb={Uri.EscapeDataString(crumb)}&modules={Uri.EscapeDataString(moduleList)}"; using var request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"); request.Headers.Add("Accept", "*/*"); request.Headers.Add("Origin", "https://finance.yahoo.com"); request.Headers.Add("Referer", "https://finance.yahoo.com/"); if (!string.IsNullOrWhiteSpace(_rawCookieHeader)) { request.Headers.Add("Cookie", _rawCookieHeader); } using var response = await _httpClient.SendAsync(request, cancellationToken); var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); if (response.IsSuccessStatusCode) { var dto = JsonSerializer.Deserialize(responseContent, GetJsonOptions()); return (false, dto); } if (_finlyticLogger != null) await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] GetQuoteSummary for '{Symbol}' failed with status {Status}. URL: {Url}. Response Body: {Body}", symbol, response.StatusCode, url, responseContent); if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden || response.StatusCode == HttpStatusCode.TooManyRequests) { return (true, null); } } return (false, null); }, cancellationToken); } /// /// Convenience method to fetch all standard quoteSummary modules for a given symbol. /// public Task GetFullQuoteSummaryAsync(string symbol, CancellationToken cancellationToken = default) { return GetQuoteSummaryAsync(symbol, StandardQuoteSummaryModules, cancellationToken); } /// /// Retrieves historical OHLCV chart data for a given symbol. /// public async Task GetChartAsync( string symbol, string range = "1y", string interval = "1d", CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(symbol)) return null; return await ExecuteWithRetryAsync(async (crumb) => { string[] baseUrls = new[] { "https://query1.finance.yahoo.com/v8/finance/chart", "https://query2.finance.yahoo.com/v8/finance/chart" }; foreach (var baseUrl in baseUrls) { var url = $"{baseUrl}/{Uri.EscapeDataString(symbol)}?range={Uri.EscapeDataString(range)}&interval={Uri.EscapeDataString(interval)}&crumb={Uri.EscapeDataString(crumb)}"; using var request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"); request.Headers.Add("Accept", "*/*"); request.Headers.Add("Origin", "https://finance.yahoo.com"); request.Headers.Add("Referer", "https://finance.yahoo.com/"); if (!string.IsNullOrWhiteSpace(_rawCookieHeader)) { request.Headers.Add("Cookie", _rawCookieHeader); } using var response = await _httpClient.SendAsync(request, cancellationToken); var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); if (response.IsSuccessStatusCode) { var dto = JsonSerializer.Deserialize(responseContent, GetJsonOptions()); return (false, dto); } if (_finlyticLogger != null) await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] GetChart for '{Symbol}' failed with status {Status}. URL: {Url}. Response Body: {Body}", symbol, response.StatusCode, url, responseContent); if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden || response.StatusCode == HttpStatusCode.TooManyRequests) { return (true, null); } } return (false, null); }, cancellationToken); } /// /// Retrieves quick real-time price quotes for one or more symbols. /// public async Task GetQuotesAsync( IEnumerable symbols, CancellationToken cancellationToken = default) { var symbolList = symbols.Where(s => !string.IsNullOrWhiteSpace(s)).ToList(); if (symbolList.Count == 0) return null; var symbolsParam = string.Join(",", symbolList); return await ExecuteWithRetryAsync(async (crumb) => { string[] baseUrls = new[] { "https://query1.finance.yahoo.com/v7/finance/quote", "https://query2.finance.yahoo.com/v7/finance/quote" }; foreach (var baseUrl in baseUrls) { var url = $"{baseUrl}?symbols={Uri.EscapeDataString(symbolsParam)}&crumb={Uri.EscapeDataString(crumb)}"; using var request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"); request.Headers.Add("Accept", "*/*"); request.Headers.Add("Origin", "https://finance.yahoo.com"); request.Headers.Add("Referer", "https://finance.yahoo.com/"); if (!string.IsNullOrWhiteSpace(_rawCookieHeader)) { request.Headers.Add("Cookie", _rawCookieHeader); } using var response = await _httpClient.SendAsync(request, cancellationToken); var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); if (response.IsSuccessStatusCode) { var dto = JsonSerializer.Deserialize(responseContent, GetJsonOptions()); return (false, dto); } if (_finlyticLogger != null) await _finlyticLogger.LogWarningAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] GetQuotes failed with status {Status}. URL: {Url}. Response Body: {Body}", response.StatusCode, url, responseContent); if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden || response.StatusCode == HttpStatusCode.TooManyRequests) { return (true, null); } } return (false, null); }, cancellationToken); } /// /// Convenient helper method to fetch the current live price for a single symbol (e.g., "^VIX"). /// public async Task GetLivePriceAsync(string symbol, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(symbol)) return null; var quotes = await GetQuotesAsync(new[] { symbol }, cancellationToken); var item = quotes?.QuoteResponse?.Result?.FirstOrDefault(); if (item?.RegularMarketPrice.HasValue == true && item.RegularMarketPrice.Value > 0) { return Convert.ToDecimal(item.RegularMarketPrice.Value); } return null; } private async Task ExecuteWithRetryAsync( Func> action, CancellationToken cancellationToken) where T : class { var crumb = await EnsureAuthenticatedAsync(false, cancellationToken); if (!string.IsNullOrEmpty(crumb)) { var (isAuthError, result) = await action(crumb); if (!isAuthError && result != null) { return result; } if (!isAuthError) { return result; } } // Re-authenticate when auth error (401/403/429) or empty crumb occurs if (_finlyticLogger != null) await _finlyticLogger.LogInfoAsync(CoreSettingKeys.YahooClientChannel, "[YahooFinanceClient] Authentication error or invalid crumb encountered (401/403/429). Force re-authenticating..."); crumb = await EnsureAuthenticatedAsync(true, cancellationToken); if (string.IsNullOrEmpty(crumb)) return null; var (_, retryResult) = await action(crumb); return retryResult; } private static JsonSerializerOptions GetJsonOptions() { return new JsonSerializerOptions { PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; } }