using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using ChatbotApi.Data.Models; using HtmlAgilityPack; using Microsoft.Extensions.Caching.Memory; using Microsoft.EntityFrameworkCore; using System.Net; using System.Globalization; namespace ChatbotApi.Services { public interface IChatService { IAsyncEnumerable StreamMessageAsync(ChatRequest request, CancellationToken cancellationToken); } public class ChatService : IChatService { private readonly IHttpClientFactory _httpClientFactory; private readonly IMemoryCache _cache; private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; private readonly string _apiUrl; private readonly AppContexto _dbContext; private static readonly string _siteUrl = "https://www.eldia.com/"; private static readonly string[] PrefijosAQuitar = { "VIDEO.- ", "VIDEO. ", "FOTOS.- ", "FOTOS. " }; const int OutTokens = 8192; private const string SystemPromptsCacheKey = "ActiveSystemPrompts"; public ChatService( IConfiguration configuration, IMemoryCache memoryCache, IServiceProvider serviceProvider, ILogger logger, IHttpClientFactory httpClientFactory, AppContexto dbContext) { _logger = logger; _cache = memoryCache; _serviceProvider = serviceProvider; _httpClientFactory = httpClientFactory; _dbContext = dbContext; var apiKey = configuration["Gemini:GeminiApiKey"] ?? throw new InvalidOperationException("La API Key de Gemini no está configurada en .env"); var baseUrl = configuration["Gemini:GeminiApiUrl"]; _apiUrl = $"{baseUrl}{apiKey}"; } public async IAsyncEnumerable StreamMessageAsync(ChatRequest request, [EnumeratorCancellation] CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(request?.Message)) { yield return "Error: No he recibido ningún mensaje."; yield break; } string safeUserMessage = SanitizeInput(request.Message); string context = ""; string promptInstructions = ""; string? articleContext = null; string? errorMessage = null; IntentType intent = IntentType.Homepage; // [OPTIMIZACIÓN] Pre-carga de prompts del sistema en paralelo var systemPromptsTask = GetActiveSystemPromptsAsync(); Task? articleTask = null; try { if (!string.IsNullOrEmpty(request.ContextUrl) && await UrlSecurity.IsSafeUrlAsync(request.ContextUrl)) { articleTask = GetArticleContentAsync(request.ContextUrl); } if (articleTask != null) articleContext = await articleTask; intent = await GetIntentAsync(safeUserMessage, articleContext, request.ConversationSummary); // [FIX] Si la intención es 'Artículo' pero no hay contexto (el usuario pregunta sobre un tema específico sin abrir una nota), // asumimos que quiere BUSCAR en la portada. if (intent == IntentType.Article && string.IsNullOrEmpty(articleContext)) { intent = IntentType.Homepage; } switch (intent) { case IntentType.Article: context = articleContext ?? "No se pudo cargar el artículo."; promptInstructions = "Responde la pregunta dentro de basándote ESTRICTA Y ÚNICAMENTE en la información dentro de ."; break; case IntentType.KnowledgeBase: var contextBuilder = new StringBuilder(); // [OPTIMIZACIÓN] Recolección de conocimiento en paralelo var knowledgeTask = GetKnowledgeItemsAsync(); var fuentesTask = GetFuentesDeContextoAsync(); await Task.WhenAll(knowledgeTask, fuentesTask); foreach (var item in knowledgeTask.Result.Values) { contextBuilder.AppendLine($"- TEMA: {item.Descripcion}\n INFORMACIÓN: {item.Valor}"); } foreach (var fuente in fuentesTask.Result) { if (await UrlSecurity.IsSafeUrlAsync(fuente.Url)) { contextBuilder.AppendLine($"\n--- {fuente.Nombre} ---"); string scrapedContent = await ScrapeUrlContentAsync(fuente); contextBuilder.AppendLine(SanitizeInput(scrapedContent)); } } context = contextBuilder.ToString(); promptInstructions = "Responde basándote ESTRICTA Y ÚNICAMENTE en la información proporcionada en ."; break; default: // No es necesario hacer scraping si solo vinculamos a la portada, // pero la lógica mantiene el scraping de 50 items aquí. // Podría optimizarse más, pero el scraping es rápido comparado con el LLM. var articles = await GetWebsiteNewsAsync(_siteUrl, 50); if (request.ShownArticles != null && request.ShownArticles.Any()) { articles = articles .Where(a => !request.ShownArticles.Contains(a.Url)) .ToList(); } // [OPTIMIZACIÓN] Búsqueda Híbrida: Intentamos localmente (rápido), si falla usamos IA (inteligente) var bestMatch = FindBestMatchingArticleLocal(safeUserMessage, articles); if (bestMatch == null) { bestMatch = await FindBestMatchingArticleAIAsync(safeUserMessage, articles, request.ConversationSummary); } if (bestMatch != null) { if (await UrlSecurity.IsSafeUrlAsync(bestMatch.Url)) { string rawContent = await GetArticleContentAsync(bestMatch.Url) ?? ""; context = SanitizeInput(rawContent); promptInstructions = $"La pregunta es sobre el artículo '{bestMatch.Title}'. Responde con un resumen conciso y ofrece el enlace: [{bestMatch.Title}]({bestMatch.Url})."; } } else { // [OPTIMIZACIÓN] Limitamos a las 15 primeras para no saturar el contexto var sb = new StringBuilder(); foreach (var article in articles.Take(15)) sb.AppendLine($"- {article.Title} ({article.Url})"); context = sb.ToString(); promptInstructions = "Actúa como un editor de noticias. Selecciona las 5 noticias más relevantes del . Para cada una, escribe una frase breve y OBLIGATORIAMENTE incluye el enlace al final con formato [Título](URL). NO inventes noticias ni enlaces."; } break; } } catch (Exception ex) { _logger.LogError(ex, "Error procesando intención."); errorMessage = "Lo siento, hubo un problema técnico procesando tu solicitud."; } yield return $"INTENT::{intent}"; if (!string.IsNullOrEmpty(errorMessage)) { yield return errorMessage; yield break; } Stream? responseStream = null; var fullBotReply = new StringBuilder(); var httpClient = _httpClientFactory.CreateClient(); httpClient.Timeout = TimeSpan.FromSeconds(30); try { var promptBuilder = new StringBuilder(); var systemInstructions = !string.IsNullOrWhiteSpace(request.SystemPromptOverride) ? request.SystemPromptOverride : await systemPromptsTask; // Esperar tarea precargada promptBuilder.AppendLine(""); promptBuilder.AppendLine("Eres DiaBot, asistente virtual de El Día (La Plata, Argentina)."); promptBuilder.AppendLine(systemInstructions); promptBuilder.AppendLine("IMPORTANTE:"); promptBuilder.AppendLine("- NO uses formatos de email/carta ('Estimado/a', 'Atentamente')."); promptBuilder.AppendLine("- NO saludes de nuevo si ya saludaste o si la pregunta es directa, ve al grano."); promptBuilder.AppendLine("- Sé conciso, directo y natural."); promptBuilder.AppendLine("- Si el usuario pregunta '¿algo más?' o '¿qué más?', asume que pide más noticias de la portada y no saludes."); promptBuilder.AppendLine(promptInstructions); try { var timeInfo = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires")); promptBuilder.AppendLine($"Fecha y hora actual: {timeInfo:dd/MM/yyyy HH:mm}"); } catch { } promptBuilder.AppendLine(""); promptBuilder.AppendLine(""); promptBuilder.AppendLine(context); promptBuilder.AppendLine(""); promptBuilder.AppendLine(""); promptBuilder.AppendLine(safeUserMessage); promptBuilder.AppendLine(""); promptBuilder.AppendLine("RESPUESTA:"); var requestData = new GeminiRequest { Contents = new[] { new Content { Parts = new[] { new Part { Text = promptBuilder.ToString() } } } }, GenerationConfig = new GenerationConfig { MaxOutputTokens = OutTokens }, SafetySettings = GetDefaultSafetySettings() }; var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _apiUrl) { Content = JsonContent.Create(requestData) }; var response = await httpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (!response.IsSuccessStatusCode) { _logger.LogWarning("Error API Gemini: {StatusCode}", response.StatusCode); throw new HttpRequestException("Error en proveedor de IA."); } responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); } catch (Exception ex) { _logger.LogError(ex, "Error en stream."); errorMessage = "Lo siento, servicio temporalmente no disponible."; } if (!string.IsNullOrEmpty(errorMessage)) { yield return errorMessage; yield break; } if (responseStream != null) { await using (responseStream) using (var reader = new StreamReader(responseStream)) { string? line; while ((line = await reader.ReadLineAsync(cancellationToken)) != null) { if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: ")) continue; var jsonString = line.Substring(6); string? chunk = null; try { var geminiResponse = JsonSerializer.Deserialize(jsonString); chunk = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text; } catch (JsonException) { continue; } if (chunk != null) { fullBotReply.Append(chunk); yield return chunk; } } } } if (fullBotReply.Length > 0) { // [OPTIMIZACIÓN] Logging "fire-and-forget" (BD) _ = Task.Run(async () => { using (var scope = _serviceProvider.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); try { db.ConversacionLogs.Add(new ConversacionLog { UsuarioMensaje = safeUserMessage, BotRespuesta = fullBotReply.ToString(), Fecha = DateTime.UtcNow }); await db.SaveChangesAsync(); } catch(Exception ex) { var logger = scope.ServiceProvider.GetRequiredService>(); logger.LogError(ex, "Error in background logging"); } } }); // [IMPORTANTE] El resumen del contexto debe permanecer en primer plano para informar al cliente var newSummary = await UpdateConversationSummaryAsync(request.ConversationSummary, safeUserMessage, fullBotReply.ToString()); yield return $"SUMMARY::{newSummary}"; } } // --- PRIVATE METHODS --- private string SanitizeInput(string? input) { if (string.IsNullOrWhiteSpace(input)) return string.Empty; return input.Replace("<", "<").Replace(">", ">"); } private async Task GetActiveSystemPromptsAsync() { return await _cache.GetOrCreateAsync(SystemPromptsCacheKey, async entry => { entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10); var prompts = await _dbContext.SystemPrompts .Where(p => p.IsActive) .OrderByDescending(p => p.CreatedAt) .Select(p => p.Content) .ToListAsync(); if (!prompts.Any()) return "Tu rol es ser el asistente virtual de 'El Día'. Responde de forma natural, útil y concisa. Usa un tono amigable pero profesional (estilo periodístico moderno). IMPORTANTE: NO uses saludos formales tipo carta (como 'Estimado/a'), NO saludes si el usuario no saludó primero o si es una continuación de la charla. NO repitas saludos."; return string.Join("\n\n", prompts); }) ?? "Responde de forma natural y concisa."; } private List GetDefaultSafetySettings() { return new List { new SafetySetting { Category = "HARM_CATEGORY_HARASSMENT", Threshold = "BLOCK_MEDIUM_AND_ABOVE" }, new SafetySetting { Category = "HARM_CATEGORY_HATE_SPEECH", Threshold = "BLOCK_MEDIUM_AND_ABOVE" }, new SafetySetting { Category = "HARM_CATEGORY_SEXUALLY_EXPLICIT", Threshold = "BLOCK_MEDIUM_AND_ABOVE" }, new SafetySetting { Category = "HARM_CATEGORY_DANGEROUS_CONTENT", Threshold = "BLOCK_MEDIUM_AND_ABOVE" } }; } private async Task UpdateConversationSummaryAsync(string? oldSummary, string userMessage, string botResponse) { string safeOldSummary = SanitizeInput(oldSummary ?? "Esta es una nueva conversación."); string safeUserMsg = SanitizeInput(userMessage); string safeBotMsg = SanitizeInput(new string(botResponse.Take(300).ToArray())); var promptBuilder = new StringBuilder(); promptBuilder.AppendLine("Tu tarea es actualizar un resumen de conversación. Basado en el y el , crea un nuevo resumen conciso."); promptBuilder.AppendLine($"{safeOldSummary}"); promptBuilder.AppendLine(""); promptBuilder.AppendLine($"Usuario: {safeUserMsg}"); promptBuilder.AppendLine($"Bot: {safeBotMsg}..."); promptBuilder.AppendLine(""); promptBuilder.AppendLine("\nResponde SOLO con el nuevo resumen."); var requestData = new GeminiRequest { Contents = new[] { new Content { Parts = new[] { new Part { Text = promptBuilder.ToString() } } } }, SafetySettings = GetDefaultSafetySettings() }; var nonStreamingApiUrl = _apiUrl.Replace(":streamGenerateContent?alt=sse&", ":generateContent?"); var httpClient = _httpClientFactory.CreateClient(); try { var response = await httpClient.PostAsJsonAsync(nonStreamingApiUrl, requestData); if (!response.IsSuccessStatusCode) return safeOldSummary; var geminiResponse = await response.Content.ReadFromJsonAsync(); var newSummary = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim(); return newSummary ?? safeOldSummary; } catch (Exception ex) { _logger.LogError(ex, "Excepción en UpdateConversationSummaryAsync."); return safeOldSummary; } } private async Task GetIntentAsync(string userMessage, string? activeArticleContent, string? conversationSummary) { string safeUserMsg = SanitizeInput(userMessage); string safeSummary = SanitizeInput(conversationSummary); string safeArticle = SanitizeInput(new string((activeArticleContent ?? "").Take(1000).ToArray())); var promptBuilder = new StringBuilder(); promptBuilder.AppendLine("Actúa como un router de intenciones. Analiza la y decide qué fuente de información usar."); promptBuilder.AppendLine("Categorías posibles: [ARTICULO_ACTUAL], [BASE_DE_CONOCIMIENTO], [NOTICIAS_PORTADA]."); if (!string.IsNullOrWhiteSpace(safeSummary)) promptBuilder.AppendLine($"{safeSummary}"); if (!string.IsNullOrEmpty(safeArticle)) promptBuilder.AppendLine($"{safeArticle}..."); promptBuilder.AppendLine("\n--- CRITERIOS DE DECISIÓN ESTRICTOS ---"); promptBuilder.AppendLine("1. [ARTICULO_ACTUAL]: SOLO si la pregunta es sobre el MISMO TEMA del ."); promptBuilder.AppendLine(" Ejemplos: '¿qué más dice?', 'cuándo pasó?', 'quién es?', 'dame detalles'."); promptBuilder.AppendLine(" IMPORTANTE: Si la pregunta menciona un tema DIFERENTE al artículo, NO uses esta categoría."); promptBuilder.AppendLine(""); promptBuilder.AppendLine("2. [NOTICIAS_PORTADA]: Si la pregunta es sobre:"); promptBuilder.AppendLine(" - Noticias generales ('¿qué hay?', '¿algo más?', 'novedades')"); promptBuilder.AppendLine(" - Un tema DIFERENTE al del artículo actual"); promptBuilder.AppendLine(" - Cualquier tema que NO esté en el "); promptBuilder.AppendLine(""); promptBuilder.AppendLine("3. [BASE_DE_CONOCIMIENTO]: Solo para preguntas sobre el diario 'El Día' como empresa/organización."); promptBuilder.AppendLine($"\n{safeUserMsg}"); promptBuilder.AppendLine("\nResponde ÚNICAMENTE con el nombre de la categoría entre corchetes. Si hay duda, usa [NOTICIAS_PORTADA]."); var requestData = new GeminiRequest { Contents = new[] { new Content { Parts = new[] { new Part { Text = promptBuilder.ToString() } } } }, SafetySettings = GetDefaultSafetySettings() }; var nonStreamingApiUrl = _apiUrl.Replace(":streamGenerateContent?alt=sse&", ":generateContent?"); var httpClient = _httpClientFactory.CreateClient(); try { var response = await httpClient.PostAsJsonAsync(nonStreamingApiUrl, requestData); if (!response.IsSuccessStatusCode) return IntentType.Homepage; var geminiResponse = await response.Content.ReadFromJsonAsync(); var responseText = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim() ?? ""; if (responseText.Contains("ARTICULO_ACTUAL")) return IntentType.Article; if (responseText.Contains("BASE_DE_CONOCIMIENTO")) return IntentType.KnowledgeBase; return IntentType.Homepage; } catch (Exception ex) { _logger.LogError(ex, "Excepción en GetIntentAsync."); return IntentType.Homepage; } } private async Task SaveConversationLogAsync(string userMessage, string botReply) { try { // usamos dbContext injectado (Scoped) directamente _dbContext.ConversacionLogs.Add(new ConversacionLog { UsuarioMensaje = userMessage, BotRespuesta = botReply, Fecha = DateTime.UtcNow }); await _dbContext.SaveChangesAsync(); } catch (Exception ex) { _logger.LogError(ex, "Error guardando log."); } } private async Task> GetWebsiteNewsAsync(string url, int cantidad) { var newsList = new List(); try { if (!await UrlSecurity.IsSafeUrlAsync(url)) return newsList; var web = new HtmlWeb(); var doc = await web.LoadFromWebAsync(url); var articleNodes = doc.DocumentNode.SelectNodes("//article[contains(@class, 'item')] | //article[contains(@class, 'nota_modulo')]"); if (articleNodes == null) return newsList; var urlsProcesadas = new HashSet(); foreach (var articleNode in articleNodes) { if (newsList.Count >= cantidad) break; var linkNode = articleNode.SelectSingleNode(".//a[@href]"); var titleNode = articleNode.SelectSingleNode(".//h2"); if (linkNode != null && titleNode != null) { var relativeUrl = linkNode.GetAttributeValue("href", string.Empty); if (!string.IsNullOrEmpty(relativeUrl) && relativeUrl != "#" && !urlsProcesadas.Contains(relativeUrl)) { var fullUrl = relativeUrl.StartsWith("/") ? new Uri(new Uri(url), relativeUrl).ToString() : relativeUrl; string cleanTitle = WebUtility.HtmlDecode(titleNode.InnerText).Trim(); foreach (var p in PrefijosAQuitar) if (cleanTitle.StartsWith(p, StringComparison.OrdinalIgnoreCase)) cleanTitle = cleanTitle.Substring(p.Length).Trim(); newsList.Add(new NewsArticleLink { Title = cleanTitle, Url = fullUrl }); urlsProcesadas.Add(relativeUrl); } } } } catch (Exception ex) { _logger.LogError(ex, "Error scraping news."); } return newsList; } private NewsArticleLink? FindBestMatchingArticleLocal(string userMessage, List articles) { if (!articles.Any() || string.IsNullOrWhiteSpace(userMessage)) return null; var userTerms = Tokenize(userMessage); if (!userTerms.Any()) return null; NewsArticleLink? bestMatch = null; double maxScore = 0; foreach (var article in articles) { var titleTerms = Tokenize(article.Title); double score = CalculateJaccardSimilarity(userTerms, titleTerms); // Boost: Palabras clave compartidas (longitud > 3) if (userTerms.Intersect(titleTerms).Any(t => t.Length > 3)) { score += 0.2; } // Aumentar puntaje si los términos son consecutivos en el título (coincidencia de frase) if (article.Title.IndexOf(userMessage, StringComparison.OrdinalIgnoreCase) >= 0) { score += 0.5; } if (score > maxScore) { maxScore = score; bestMatch = article; } } // Umbral mínimo de relevancia: Reducido a 0.05 para capturar coincidencias de una sola palabra en títulos largos return maxScore >= 0.05 ? bestMatch : null; } private async Task FindBestMatchingArticleAIAsync(string userMessage, List articles, string? conversationSummary) { if (!articles.Any()) return null; string safeUserMsg = SanitizeInput(userMessage); string safeSummary = SanitizeInput(conversationSummary); var promptBuilder = new StringBuilder(); promptBuilder.AppendLine("Encuentra el artículo más relevante para la en la , usando el para entender referencias (ej: 'esa nota')."); if (!string.IsNullOrWhiteSpace(safeSummary)) { promptBuilder.AppendLine(""); promptBuilder.AppendLine(safeSummary); promptBuilder.AppendLine(""); } promptBuilder.AppendLine(""); foreach (var article in articles) promptBuilder.AppendLine($"- Título: \"{article.Title}\", URL: {article.Url}"); promptBuilder.AppendLine(""); promptBuilder.AppendLine($"{safeUserMsg}"); promptBuilder.AppendLine("Responde SOLO con la URL. Si ninguna es relevante, responde 'N/A'."); var requestData = new GeminiRequest { Contents = new[] { new Content { Parts = new[] { new Part { Text = promptBuilder.ToString() } } } }, SafetySettings = GetDefaultSafetySettings() }; var nonStreamingApiUrl = _apiUrl.Replace(":streamGenerateContent?alt=sse&", ":generateContent?"); var httpClient = _httpClientFactory.CreateClient(); try { var response = await httpClient.PostAsJsonAsync(nonStreamingApiUrl, requestData); if (!response.IsSuccessStatusCode) return null; var geminiResponse = await response.Content.ReadFromJsonAsync(); var responseUrl = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim(); if (string.IsNullOrEmpty(responseUrl) || responseUrl == "N/A") return null; return articles.FirstOrDefault(a => a.Url == responseUrl); } catch { return null; } } private HashSet Tokenize(string text) { var normalizedText = RemoveDiacritics(text.ToLower()); var punctuation = normalizedText.Where(char.IsPunctuation).Distinct().ToArray(); return normalizedText .Split() .Select(x => x.Trim(punctuation)) .Where(x => x.Length > 2) // ignorar palabras muy cortas .ToHashSet(); } private string RemoveDiacritics(string text) { var normalizedString = text.Normalize(NormalizationForm.FormD); var stringBuilder = new StringBuilder(capacity: normalizedString.Length); for (int i = 0; i < normalizedString.Length; i++) { char c = normalizedString[i]; var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); if (unicodeCategory != UnicodeCategory.NonSpacingMark) { stringBuilder.Append(c); } } return stringBuilder.ToString().Normalize(NormalizationForm.FormC); } private double CalculateJaccardSimilarity(HashSet set1, HashSet set2) { if (!set1.Any() || !set2.Any()) return 0.0; var intersection = new HashSet(set1); intersection.IntersectWith(set2); var union = new HashSet(set1); union.UnionWith(set2); return (double)intersection.Count / union.Count; } private async Task> GetKnowledgeItemsAsync() { return await _cache.GetOrCreateAsync(CacheKeys.KnowledgeItems, async entry => { entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); using (var scope = _serviceProvider.CreateScope()) { var dbContext = scope.ServiceProvider.GetRequiredService(); return await dbContext.ContextoItems.AsNoTracking().ToDictionaryAsync(item => item.Clave, item => item); } }) ?? new Dictionary(); } private async Task> GetFuentesDeContextoAsync() { return await _cache.GetOrCreateAsync(CacheKeys.FuentesDeContexto, async entry => { entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); using (var scope = _serviceProvider.CreateScope()) { var dbContext = scope.ServiceProvider.GetRequiredService(); return await dbContext.FuentesDeContexto.Where(f => f.Activo).AsNoTracking().ToListAsync(); } }) ?? new List(); } private async Task GetArticleContentAsync(string url) { if (!await UrlSecurity.IsSafeUrlAsync(url)) return null; try { var web = new HtmlWeb(); var doc = await web.LoadFromWebAsync(url); var paragraphs = doc.DocumentNode.SelectNodes("//div[contains(@class, 'cuerpo_nota')]//p"); if (paragraphs == null || !paragraphs.Any()) return null; var sb = new StringBuilder(); foreach (var p in paragraphs) { var cleanText = WebUtility.HtmlDecode(p.InnerText).Trim(); if (!string.IsNullOrWhiteSpace(cleanText)) sb.AppendLine(cleanText); } return sb.ToString(); } catch { return null; } } private async Task ScrapeUrlContentAsync(FuenteContexto fuente) { if (!await UrlSecurity.IsSafeUrlAsync(fuente.Url)) return string.Empty; return await _cache.GetOrCreateAsync($"scrape_{fuente.Url}_{fuente.SelectorContenido}", async entry => { entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30); try { var web = new HtmlWeb(); var doc = await web.LoadFromWebAsync(fuente.Url); string selector = !string.IsNullOrWhiteSpace(fuente.SelectorContenido) ? fuente.SelectorContenido : "//main | //body"; var node = doc.DocumentNode.SelectSingleNode(selector); if (node == null) return string.Empty; return WebUtility.HtmlDecode(node.InnerText) ?? string.Empty; } catch { return string.Empty; } }) ?? string.Empty; } } }