Files
Chatbot-ElDia/ChatbotApi/Constrollers/ChatController.cs

550 lines
26 KiB
C#
Raw Normal View History

2025-11-18 14:34:26 -03:00
// ChatbotApi/Controllers/ChatController.cs
using Microsoft.AspNetCore.Mvc;
using ChatbotApi.Models;
using ChatbotApi.Data.Models;
using System.Net;
using System.Text;
using System.Text.Json.Serialization;
using HtmlAgilityPack;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Caching.Memory;
using System.Runtime.CompilerServices;
using System.Text.Json;
// Clases de Request/Response
2025-11-20 15:24:47 -03:00
public class GenerationConfig
{
[JsonPropertyName("maxOutputTokens")]
public int MaxOutputTokens { get; set; }
}
public class GeminiRequest
{
[JsonPropertyName("contents")]
public Content[] Contents { get; set; } = default!;
[JsonPropertyName("generationConfig")]
public GenerationConfig? GenerationConfig { get; set; }
}
2025-11-18 14:34:26 -03:00
public class Content { [JsonPropertyName("parts")] public Part[] Parts { get; set; } = default!; }
public class Part { [JsonPropertyName("text")] public string Text { get; set; } = default!; }
public class GeminiResponse { [JsonPropertyName("candidates")] public Candidate[] Candidates { get; set; } = default!; }
public class Candidate { [JsonPropertyName("content")] public Content Content { get; set; } = default!; }
public class GeminiStreamingResponse { [JsonPropertyName("candidates")] public StreamingCandidate[] Candidates { get; set; } = default!; }
public class StreamingCandidate { [JsonPropertyName("content")] public Content Content { get; set; } = default!; }
2025-11-21 11:20:44 -03:00
public enum IntentType { Article, Database, Homepage, ExternalSource }
2025-11-18 14:34:26 -03:00
namespace ChatbotApi.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class ChatController : ControllerBase
{
private readonly string _apiUrl;
private readonly IMemoryCache _cache;
private readonly IServiceProvider _serviceProvider;
2025-11-18 14:34:26 -03:00
private readonly ILogger<ChatController> _logger;
private static readonly HttpClient _httpClient = new HttpClient();
private static readonly string _knowledgeCacheKey = "KnowledgeBase";
2025-11-18 14:34:26 -03:00
private static readonly string _siteUrl = "https://www.eldia.com/";
private static readonly string[] PrefijosAQuitar = { "VIDEO.- ", "VIDEO. ", "FOTOS.- ", "FOTOS. " };
2025-11-20 15:24:47 -03:00
const int OutTokens = 8192;
2025-11-18 14:34:26 -03:00
public ChatController(IConfiguration configuration, IMemoryCache memoryCache, IServiceProvider serviceProvider, ILogger<ChatController> logger)
{
_logger = logger;
_cache = memoryCache;
_serviceProvider = serviceProvider;
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}";
}
2025-11-20 12:39:23 -03:00
private async Task<string> UpdateConversationSummaryAsync(string? oldSummary, string userMessage, string botResponse)
{
if (string.IsNullOrWhiteSpace(oldSummary))
{
oldSummary = "Esta es una nueva conversación.";
}
var promptBuilder = new StringBuilder();
promptBuilder.AppendLine("Tu tarea es actualizar un resumen de conversación. Basado en el RESUMEN ANTERIOR y el ÚLTIMO INTERCAMBIO, crea un nuevo resumen conciso. Mantén solo los puntos clave y el tema principal de la conversación.");
promptBuilder.AppendLine("\n--- RESUMEN ANTERIOR ---");
promptBuilder.AppendLine(oldSummary);
promptBuilder.AppendLine("\n--- ÚLTIMO INTERCAMBIO ---");
promptBuilder.AppendLine($"Usuario: \"{userMessage}\"");
promptBuilder.AppendLine($"Bot: \"{new string(botResponse.Take(300).ToArray())}...\"");
promptBuilder.AppendLine("\n--- NUEVO RESUMEN CONCISO ---");
var finalPrompt = promptBuilder.ToString();
var requestData = new GeminiRequest { Contents = new[] { new Content { Parts = new[] { new Part { Text = finalPrompt } } } } };
var nonStreamingApiUrl = _apiUrl.Replace(":streamGenerateContent?alt=sse&", ":generateContent?");
try
{
var response = await _httpClient.PostAsJsonAsync(nonStreamingApiUrl, requestData);
if (!response.IsSuccessStatusCode) return oldSummary ?? "";
var geminiResponse = await response.Content.ReadFromJsonAsync<GeminiResponse>();
var newSummary = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim();
_logger.LogInformation("Resumen de conversación actualizado: '{NewSummary}'", newSummary);
return newSummary ?? oldSummary ?? "";
}
catch (Exception ex)
{
_logger.LogError(ex, "Excepción en UpdateConversationSummaryAsync. Se mantendrá el resumen anterior.");
return oldSummary ?? "";
}
}
2025-11-21 11:20:44 -03:00
private async Task<(IntentType intent, string? data)> GetIntentAsync(string userMessage, string? activeArticleContent, string? conversationSummary, Dictionary<string, ContextoItem> knowledgeBase)
{
var promptBuilder = new StringBuilder();
2025-11-21 11:20:44 -03:00
promptBuilder.AppendLine("Tu tarea es actuar como un router de intenciones...");
promptBuilder.AppendLine("- [ARTICULO_ACTUAL]");
promptBuilder.AppendLine("- [NOTICIAS_PORTADA]");
promptBuilder.AppendLine("- [BASE_DE_DATOS:CLAVE_SELECCIONADA]");
// --- LÓGICA DINÁMICA ---
List<FuenteContexto> fuentesExternas;
using (var scope = _serviceProvider.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<AppContexto>();
fuentesExternas = await dbContext.FuentesDeContexto.Where(f => f.Activo).ToListAsync();
}
foreach (var fuente in fuentesExternas)
{
promptBuilder.AppendLine($"[FUENTE_EXTERNA:{fuente.Url}]: Úsala si la pregunta trata sobre: {fuente.DescripcionParaIA}");
}
promptBuilder.AppendLine("\n--- DESCRIPCIÓN DE HERRAMIENTAS ---");
2025-11-21 11:20:44 -03:00
promptBuilder.AppendLine("[ARTICULO_ACTUAL]: Úsala si la pregunta es una continuación directa sobre el artículo que se está discutiendo.");
promptBuilder.AppendLine("[NOTICIAS_PORTADA]: Úsala para preguntas generales sobre noticias actuales o eventos.");
promptBuilder.AppendLine("[BASE_DE_DATOS:CLAVE_SELECCIONADA]: Úsala para preguntas sobre información específica del diario. DEBES reemplazar 'CLAVE_SELECCIONADA' con la clave más relevante de la siguiente lista:");
var dbKeys = string.Join(", ", knowledgeBase.Keys);
promptBuilder.AppendLine($" - Claves disponibles: {dbKeys}");
if (!string.IsNullOrWhiteSpace(conversationSummary))
{
promptBuilder.AppendLine("\n--- RESUMEN DE LA CONVERSACIÓN ACTUAL ---");
promptBuilder.AppendLine(conversationSummary);
}
if (!string.IsNullOrEmpty(activeArticleContent))
{
2025-11-21 11:20:44 -03:00
promptBuilder.AppendLine("\n--- CONTEXTO DEL ARTÍCULO ACTUAL ---");
promptBuilder.AppendLine(new string(activeArticleContent.Take(500).ToArray()) + "...");
}
promptBuilder.AppendLine("\n--- PREGUNTA DEL USUARIO ---");
promptBuilder.AppendLine(userMessage);
2025-11-21 11:20:44 -03:00
promptBuilder.AppendLine("\n--- HERRAMIENTA Y DATOS SELECCIONADOS ---");
var finalPrompt = promptBuilder.ToString();
var requestData = new GeminiRequest { Contents = new[] { new Content { Parts = new[] { new Part { Text = finalPrompt } } } } };
var nonStreamingApiUrl = _apiUrl.Replace(":streamGenerateContent?alt=sse&", ":generateContent?");
try
{
var response = await _httpClient.PostAsJsonAsync(nonStreamingApiUrl, requestData);
2025-11-21 11:20:44 -03:00
if (!response.IsSuccessStatusCode) return (IntentType.Homepage, null);
2025-11-18 14:34:26 -03:00
var geminiResponse = await response.Content.ReadFromJsonAsync<GeminiResponse>();
var responseText = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim() ?? "";
2025-11-21 11:20:44 -03:00
_logger.LogInformation("Intención y datos detectados: {Response}", responseText);
if (responseText.Contains("ARTICULO_ACTUAL")) return (IntentType.Article, null);
if (responseText.Contains("NOTICIAS_PORTADA")) return (IntentType.Homepage, null);
if (responseText.Contains("BASE_DE_DATOS:"))
{
var key = responseText.Split(new[] { ':' }, 2)[1].TrimEnd(']');
return (IntentType.Database, key);
}
if (responseText.Contains("FUENTE_EXTERNA:"))
{
var url = responseText.Split(new[] { ':' }, 2, StringSplitOptions.None)[1].TrimEnd(']');
return (IntentType.ExternalSource, url); // Necesitaremos un nuevo IntentType
}
2025-11-21 11:20:44 -03:00
return (IntentType.Homepage, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Excepción en GetIntentAsync. Usando fallback a Homepage.");
2025-11-21 11:20:44 -03:00
return (IntentType.Homepage, null);
}
}
2025-11-20 12:39:23 -03:00
2025-11-21 11:20:44 -03:00
2025-11-18 14:34:26 -03:00
[HttpPost("stream-message")]
[EnableRateLimiting("fixed")]
public async IAsyncEnumerable<string> StreamMessage(
2025-11-20 15:24:47 -03:00
[FromBody] ChatRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
2025-11-18 14:34:26 -03:00
{
if (string.IsNullOrWhiteSpace(request?.Message))
{
yield return "Error: No he recibido ningún mensaje.";
yield break;
}
string userMessage = request.Message;
string context = "";
string promptInstructions = "";
string? articleContext = null;
string? errorMessage = null;
2025-11-20 15:24:47 -03:00
IntentType intent = IntentType.Homepage;
2025-11-20 12:39:23 -03:00
2025-11-18 14:34:26 -03:00
try
{
if (!string.IsNullOrEmpty(request.ContextUrl))
{
articleContext = await GetArticleContentAsync(request.ContextUrl);
}
2025-11-21 11:20:44 -03:00
var knowledgeBase = await GetKnowledgeAsync();
// --- CORRECCIÓN 2: El código que llama a GetIntentAsync debe esperar una tupla ---
var (detectedIntent, intentData) = await GetIntentAsync(userMessage, articleContext, request.ConversationSummary, knowledgeBase);
intent = detectedIntent;
switch (intent)
{
case IntentType.Article:
_logger.LogInformation("Ejecutando intención: Artículo Actual.");
context = articleContext ?? "No se pudo cargar el artículo.";
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO' que contiene el texto completo de una noticia.";
break;
case IntentType.Database:
2025-11-21 11:20:44 -03:00
// --- CORRECCIÓN 3: La lógica aquí debe manejar la clave recibida ---
_logger.LogInformation("Ejecutando intención: Base de Datos con clave '{Key}'.", intentData);
if (intentData != null && knowledgeBase.TryGetValue(intentData, out var dbItem))
{
// Ahora dbItem es un objeto ContextoItem, no un string.
var dbContextBuilder = new StringBuilder();
dbContextBuilder.AppendLine("Aquí tienes la información solicitada:");
dbContextBuilder.AppendLine($"- PREGUNTA: {dbItem.Descripcion}");
dbContextBuilder.AppendLine($" RESPUESTA: {dbItem.Valor}");
context = dbContextBuilder.ToString();
}
else
{
context = "No se encontró información relevante para la clave solicitada.";
_logger.LogWarning("La clave '{Key}' devuelta por la IA no es válida.", intentData);
}
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO' que contiene la pregunta y respuesta encontrada.";
break;
case IntentType.Homepage:
default:
_logger.LogInformation("Ejecutando intención: Noticias de Portada.");
2025-11-20 12:39:23 -03:00
context = await GetWebsiteNewsAsync(_siteUrl, 25);
2025-11-21 11:20:44 -03:00
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO' que contiene una lista de noticias de portada.";
break;
case IntentType.ExternalSource:
_logger.LogInformation("Ejecutando intención: Fuente Externa con URL '{Url}'.", intentData);
if (!string.IsNullOrEmpty(intentData))
{
context = await ScrapeUrlContentAsync(intentData);
}
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO' que contiene el texto de una página web.";
break;
}
2025-11-18 14:34:26 -03:00
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al procesar la intención y el contexto.");
errorMessage = "Error: Lo siento, estoy teniendo un problema técnico al procesar tu pregunta.";
2025-11-18 14:34:26 -03:00
}
2025-11-20 12:39:23 -03:00
yield return $"INTENT::{intent}";
2025-11-18 14:34:26 -03:00
if (!string.IsNullOrEmpty(errorMessage))
{
yield return errorMessage;
yield break;
}
Stream? responseStream = null;
var fullBotReply = new StringBuilder();
2025-11-18 14:34:26 -03:00
try
{
var promptBuilder = new StringBuilder();
promptBuilder.AppendLine("INSTRUCCIONES:");
2025-11-20 15:24:47 -03:00
promptBuilder.AppendLine("Eres DiaBot, el asistente virtual del periódico El Día. Tu personalidad es profesional, servicial y concisa. Responde siempre en español Rioplatense.");
promptBuilder.AppendLine(promptInstructions);
2025-11-18 14:34:26 -03:00
promptBuilder.AppendLine("NUNCA INVENTES información. Si la respuesta no está en el contexto, indica amablemente que no encontraste la información.");
promptBuilder.AppendLine("\nCONTEXTO:\n---");
promptBuilder.AppendLine(context);
promptBuilder.AppendLine("---\n\nPREGUNTA DEL USUARIO:\n---");
promptBuilder.AppendLine(userMessage);
promptBuilder.AppendLine("---\n\nRESPUESTA:");
string finalPrompt = promptBuilder.ToString();
var streamingApiUrl = _apiUrl;
2025-11-20 15:24:47 -03:00
var requestData = new GeminiRequest
{
Contents = new[] { new Content { Parts = new[] { new Part { Text = finalPrompt } } } },
GenerationConfig = new GenerationConfig { MaxOutputTokens = OutTokens }
};
2025-11-18 14:34:26 -03:00
var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, streamingApiUrl);
httpRequestMessage.Content = JsonContent.Create(requestData);
var response = await _httpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
2025-11-20 15:24:47 -03:00
_logger.LogWarning("La API (Streaming) devolvió un error. Status: {StatusCode}, Content: {ErrorContent}", response.StatusCode, errorContent);
throw new HttpRequestException("La API devolvió un error.");
2025-11-18 14:34:26 -03:00
}
responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error inesperado durante la configuración del stream.");
errorMessage = "Error: Lo siento, estoy teniendo un problema técnico.";
}
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;
2025-11-18 14:34:26 -03:00
try
{
var geminiResponse = JsonSerializer.Deserialize<GeminiStreamingResponse>(jsonString);
chunk = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text;
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "No se pudo deserializar un chunk del stream: {JsonChunk}", jsonString);
continue;
}
if (chunk != null)
{
fullBotReply.Append(chunk);
yield return chunk;
}
}
}
}
if (fullBotReply.Length > 0)
{
// Guardamos el log de la conversación como antes
2025-11-18 14:34:26 -03:00
await SaveConversationLogAsync(userMessage, fullBotReply.ToString());
// Creamos el nuevo resumen
var newSummary = await UpdateConversationSummaryAsync(request.ConversationSummary, userMessage, fullBotReply.ToString());
// Enviamos el nuevo resumen al frontend como el último mensaje del stream
yield return $"SUMMARY::{newSummary}";
2025-11-18 14:34:26 -03:00
}
}
private async Task SaveConversationLogAsync(string userMessage, string botReply)
{
try
{
using (var scope = _serviceProvider.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<AppContexto>();
var logEntry = new ConversacionLog
{
UsuarioMensaje = userMessage,
BotRespuesta = botReply,
Fecha = DateTime.UtcNow
};
dbContext.ConversacionLogs.Add(logEntry);
await dbContext.SaveChangesAsync();
}
}
catch (Exception logEx)
{
_logger.LogError(logEx, "Error al guardar el log de la conversación después del streaming.");
}
}
2025-11-20 12:39:23 -03:00
2025-11-18 14:34:26 -03:00
private async Task<string> GetWebsiteNewsAsync(string url, int cantidad)
{
try
{
var web = new HtmlWeb();
var doc = await web.LoadFromWebAsync(url);
var articleNodes = doc.DocumentNode.SelectNodes("//article[contains(@class, 'item')]");
if (articleNodes == null || !articleNodes.Any())
{
_logger.LogWarning("No se encontraron nodos de <article> en la URL {Url}", url);
return string.Empty;
}
2025-11-18 14:34:26 -03:00
var contextBuilder = new StringBuilder();
contextBuilder.AppendLine("Lista de noticias principales extraídas de la página:");
var urlsProcesadas = new HashSet<string>();
int count = 0;
foreach (var articleNode in articleNodes)
2025-11-18 14:34:26 -03:00
{
var linkNode = articleNode.SelectSingleNode(".//a[@href]");
var titleNode = articleNode.SelectSingleNode(".//h2");
2025-11-18 14:34:26 -03:00
if (linkNode != null && titleNode != null)
2025-11-18 14:34:26 -03:00
{
var relativeUrl = linkNode.GetAttributeValue("href", string.Empty);
if (string.IsNullOrEmpty(relativeUrl) || relativeUrl == "#" || urlsProcesadas.Contains(relativeUrl))
{
continue;
}
var cleanTitle = CleanTitleText(titleNode.InnerText);
var fullUrl = relativeUrl.StartsWith("/") ? new Uri(new Uri(url), relativeUrl).ToString() : relativeUrl;
contextBuilder.AppendLine($"- Título: \"{cleanTitle}\", URL: {fullUrl}");
urlsProcesadas.Add(relativeUrl);
2025-11-18 14:34:26 -03:00
count++;
}
if (count >= cantidad)
{
break;
}
2025-11-18 14:34:26 -03:00
}
var result = contextBuilder.ToString();
_logger.LogInformation("Scraping de la portada exitoso. Se encontraron {Count} noticias.", count);
return result;
2025-11-18 14:34:26 -03:00
}
catch (Exception ex)
{
_logger.LogError(ex, "No se pudo descargar o procesar la URL {Url}", url);
return string.Empty;
}
}
private string CleanTitleText(string texto)
{
if (string.IsNullOrWhiteSpace(texto)) return string.Empty;
var textoDecodificado = WebUtility.HtmlDecode(texto).Trim();
foreach (var prefijo in PrefijosAQuitar)
{
if (textoDecodificado.StartsWith(prefijo, StringComparison.OrdinalIgnoreCase))
{
textoDecodificado = textoDecodificado.Substring(prefijo.Length).Trim();
break;
}
}
return textoDecodificado;
}
2025-11-21 11:20:44 -03:00
private async Task<Dictionary<string, ContextoItem>> GetKnowledgeAsync()
2025-11-18 14:34:26 -03:00
{
return await _cache.GetOrCreateAsync(_knowledgeCacheKey, async entry =>
{
_logger.LogInformation("La caché de conocimiento no existe o ha expirado. Recargando desde la base de datos...");
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
using (var scope = _serviceProvider.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<AppContexto>();
2025-11-21 11:20:44 -03:00
// Usamos ToDictionaryAsync para obtener el objeto ContextoItem completo.
2025-11-18 14:34:26 -03:00
var knowledge = await dbContext.ContextoItems
.AsNoTracking()
2025-11-21 11:20:44 -03:00
.ToDictionaryAsync(item => item.Clave, item => item);
2025-11-18 14:34:26 -03:00
_logger.LogInformation($"Caché actualizada con {knowledge.Count} items.");
return knowledge;
}
2025-11-21 11:20:44 -03:00
}) ?? new Dictionary<string, ContextoItem>();
}
private async Task<string?> GetArticleContentAsync(string url)
{
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())
{
_logger.LogWarning("No se encontraron párrafos en la URL {Url} con el selector '//div[contains(@class, 'cuerpo_nota')]//p'.", url);
return null;
}
var articleText = new StringBuilder();
foreach (var p in paragraphs)
{
var cleanText = WebUtility.HtmlDecode(p.InnerText).Trim();
if (!string.IsNullOrWhiteSpace(cleanText))
{
articleText.AppendLine(cleanText);
}
}
_logger.LogInformation("Se extrajo con éxito el contenido del artículo de {Url}", url);
return articleText.ToString();
}
catch (Exception ex)
{
_logger.LogError(ex, "No se pudo descargar o procesar el contenido del artículo de la URL {Url}", url);
return null;
}
2025-11-18 14:34:26 -03:00
}
2025-11-21 11:20:44 -03:00
private async Task<string> ScrapeUrlContentAsync(string url)
{
// Usamos la URL como clave de caché para no scrapear la misma página una y otra vez.
return await _cache.GetOrCreateAsync(url, async entry =>
{
_logger.LogInformation("Contenido de {Url} no encontrado en caché. Scrapeando...", url);
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1); // Cachear por 1 hora
var web = new HtmlWeb();
var doc = await web.LoadFromWebAsync(url);
// Selector genérico que intenta obtener el contenido principal
// Esto puede necesitar ajustes dependiendo de la estructura de las páginas
var mainContentNode = doc.DocumentNode.SelectSingleNode("//main") ?? doc.DocumentNode.SelectSingleNode("//body");
if (mainContentNode == null) return string.Empty;
// Podríamos hacer esto mucho más inteligente, buscando <p>, <h2>, <li>, etc.
// pero para empezar, InnerText es un buen punto de partida.
return WebUtility.HtmlDecode(mainContentNode.InnerText);
}) ?? string.Empty;
}
2025-11-18 14:34:26 -03:00
}
}