Fase 5: Implementada la configuración dinámica. Implementado el scraping web.

This commit is contained in:
2025-10-28 12:56:42 -03:00
parent 9be62937bd
commit 75d06820aa
11 changed files with 395 additions and 21 deletions

View File

@@ -0,0 +1,48 @@
// backend/src/Titulares.Api/Controllers/ConfiguracionController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using System.Text.Json;
using Titulares.Api.Models;
[ApiController]
[Route("api/[controller]")]
public class ConfiguracionController : ControllerBase
{
private readonly IOptionsMonitor<ConfiguracionApp> _configuracionMonitor;
private readonly string _configFilePath = "configuracion.json";
public ConfiguracionController(IOptionsMonitor<ConfiguracionApp> configuracionMonitor)
{
_configuracionMonitor = configuracionMonitor;
}
[HttpGet]
public IActionResult ObtenerConfiguracion()
{
// IOptionsMonitor siempre nos da el valor más reciente.
return Ok(_configuracionMonitor.CurrentValue);
}
[HttpPost]
public async Task<IActionResult> GuardarConfiguracion([FromBody] ConfiguracionApp nuevaConfiguracion)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
try
{
var options = new JsonSerializerOptions { WriteIndented = true };
var jsonString = JsonSerializer.Serialize(nuevaConfiguracion, options);
await System.IO.File.WriteAllTextAsync(_configFilePath, jsonString);
// No es necesario reiniciar la app, gracias a `reloadOnChange: true` y IOptionsMonitor.
return Ok(new { message = "Configuración guardada correctamente." });
}
catch (Exception ex)
{
return StatusCode(500, $"Error al guardar el archivo de configuración: {ex.Message}");
}
}
}

View File

@@ -82,4 +82,70 @@ public class TitularRepositorio
return false;
}
}
public async Task SincronizarDesdeScraping(List<ArticuloScrapeado> articulosScrapeados, int limiteTotal)
{
using var connection = CreateConnection();
await connection.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
// 1. Obtener las URLs que ya tenemos en la base de datos
var urlsEnDb = (await connection.QueryAsync<string>(
"SELECT UrlFuente FROM Titulares WHERE UrlFuente IS NOT NULL", transaction: transaction))
.ToHashSet();
// 2. Filtrar para quedarnos solo con los artículos que son realmente nuevos
var articulosNuevos = articulosScrapeados
.Where(a => !urlsEnDb.Contains(a.UrlFuente))
.ToList();
if (articulosNuevos.Any())
{
var cantidadNuevos = articulosNuevos.Count;
// 3. Hacer espacio para los nuevos: empujamos todos los existentes hacia abajo
await connection.ExecuteAsync(
"UPDATE Titulares SET OrdenVisual = OrdenVisual + @cantidadNuevos",
new { cantidadNuevos },
transaction: transaction);
// 4. Insertar los nuevos artículos al principio (OrdenVisual 0, 1, 2...)
for (int i = 0; i < cantidadNuevos; i++)
{
var articulo = articulosNuevos[i];
var sqlInsert = @"
INSERT INTO Titulares (Texto, UrlFuente, ModificadoPorUsuario, EsEntradaManual, OrdenVisual, Tipo, Fuente)
VALUES (@Texto, @UrlFuente, 0, 0, @OrdenVisual, 'Scraped', 'eldia.com');
";
await connection.ExecuteAsync(sqlInsert, new
{
articulo.Texto,
articulo.UrlFuente,
OrdenVisual = i
}, transaction: transaction);
}
// 5. Purgar los más antiguos si superamos el límite, ignorando los manuales
var sqlDelete = @"
DELETE FROM Titulares
WHERE Id IN (
SELECT Id FROM Titulares
WHERE EsEntradaManual = 0
ORDER BY OrdenVisual DESC
OFFSET @limiteTotal ROWS
);
";
await connection.ExecuteAsync(sqlDelete, new { limiteTotal }, transaction: transaction);
}
transaction.Commit();
}
catch
{
transaction.Rollback();
throw; // Relanzamos la excepción para que el worker sepa que algo falló
}
}
}

View File

@@ -0,0 +1,9 @@
// backend/src/Titulares.Api/Models/ArticuloScrapeado.cs
namespace Titulares.Api.Models;
public class ArticuloScrapeado
{
public required string Texto { get; set; }
public required string UrlFuente { get; set; }
}

View File

@@ -0,0 +1,11 @@
// backend/src/Titulares.Api/Models/ConfiguracionApp.cs
namespace Titulares.Api.Models;
public class ConfiguracionApp
{
public string RutaCsv { get; set; } = "C:\\temp\\titulares.csv";
public int IntervaloMinutos { get; set; } = 5;
public int CantidadTitularesAScrapear { get; set; } = 20;
public int LimiteTotalEnDb { get; set; } = 50;
}

View File

@@ -1,16 +1,24 @@
// backend/src/Titulares.Api/Program.cs
using Titulares.Api.Data;
using Titulares.Api.Hubs; // Añadir este using
using Titulares.Api.Hubs;
using Titulares.Api.Models;
using Titulares.Api.Services;
using Titulares.Api.Workers;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddJsonFile("configuracion.json", optional: false, reloadOnChange: true);
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.Configure<ConfiguracionApp>(builder.Configuration);
// Añadimos nuestro repositorio personalizado
builder.Services.AddSingleton<TitularRepositorio>();
builder.Services.AddScoped<ScrapingService>();
// Añadimos la política de CORS
builder.Services.AddCors(options =>
@@ -26,6 +34,8 @@ builder.Services.AddCors(options =>
builder.Services.AddSignalR();
builder.Services.AddHostedService<ProcesoScrapingWorker>();
// Añadimos los servicios de autorización (necesario para app.UseAuthorization)
builder.Services.AddAuthorization();

View File

@@ -0,0 +1,80 @@
// backend/src/Titulares.Api/Services/ScrapingService.cs
using HtmlAgilityPack;
using Titulares.Api.Models;
namespace Titulares.Api.Services;
public class ScrapingService
{
private const string ElDiaUrl = "https://www.eldia.com/";
// Lista de prefijos a eliminar.
private static readonly string[] PrefijosAQuitar = { "VIDEO.- ", "VIDEO. ", "FOTOS.- ", "FOTOS. " };
public async Task<List<ArticuloScrapeado>> ObtenerUltimosTitulares(int cantidad)
{
var articulos = new List<ArticuloScrapeado>();
var web = new HtmlWeb();
var doc = await web.LoadFromWebAsync(ElDiaUrl);
var nodosDeEnlace = doc.DocumentNode.SelectNodes("//article[contains(@class, 'item')]/a[@href]");
if (nodosDeEnlace != null)
{
var urlsProcesadas = new HashSet<string>();
foreach (var nodoEnlace in nodosDeEnlace)
{
var urlRelativa = nodoEnlace.GetAttributeValue("href", string.Empty);
if (string.IsNullOrEmpty(urlRelativa) || urlRelativa == "#" || urlsProcesadas.Contains(urlRelativa))
{
continue;
}
// Buscamos un <h1> O un <h2> dentro del enlace.
// El operador | en XPath significa "OR". Esto captura ambos casos.
var nodoTitulo = nodoEnlace.SelectSingleNode(".//h1 | .//h2");
if (nodoTitulo != null)
{
var textoLimpio = LimpiarTextoTitular(nodoTitulo.InnerText);
var urlCompleta = urlRelativa.StartsWith("/") ? new Uri(new Uri(ElDiaUrl), urlRelativa).ToString() : urlRelativa;
articulos.Add(new ArticuloScrapeado
{
Texto = textoLimpio,
UrlFuente = urlCompleta
});
urlsProcesadas.Add(urlRelativa);
}
if (articulos.Count >= cantidad)
{
break;
}
}
}
return articulos;
}
private string LimpiarTextoTitular(string texto)
{
if (string.IsNullOrWhiteSpace(texto))
{
return string.Empty;
}
var textoDecodificado = System.Net.WebUtility.HtmlDecode(texto).Trim();
foreach (var prefijo in PrefijosAQuitar)
{
if (textoDecodificado.StartsWith(prefijo, StringComparison.OrdinalIgnoreCase))
{
textoDecodificado = textoDecodificado.Substring(prefijo.Length).Trim();
break; // Una vez que encontramos y quitamos un prefijo, salimos del bucle.
}
}
return textoDecodificado;
}
}

View File

@@ -0,0 +1,77 @@
// backend/src/Titulares.Api/Workers/ProcesoScrapingWorker.cs
using Microsoft.AspNetCore.SignalR;
using Titulares.Api.Data;
using Titulares.Api.Hubs;
using Titulares.Api.Services;
using Microsoft.Extensions.Options;
using Titulares.Api.Models;
namespace Titulares.Api.Workers;
public class ProcesoScrapingWorker : BackgroundService
{
private readonly ILogger<ProcesoScrapingWorker> _logger;
private readonly IServiceProvider _serviceProvider;
private readonly IOptionsMonitor<ConfiguracionApp> _configuracion;
public ProcesoScrapingWorker(ILogger<ProcesoScrapingWorker> logger, IServiceProvider serviceProvider, IOptionsMonitor<ConfiguracionApp> configuracion)
{
_logger = logger;
_serviceProvider = serviceProvider;
_configuracion = configuracion;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var configActual = _configuracion.CurrentValue;
_logger.LogInformation("Iniciando ciclo de scraping con cantidad: {cantidad}", configActual.CantidadTitularesAScrapear);
try
{
// Creamos un 'scope' para obtener instancias 'scoped' de nuestros servicios.
// Es la práctica correcta en servicios de larga duración.
using (var scope = _serviceProvider.CreateScope())
{
var repositorio = scope.ServiceProvider.GetRequiredService<TitularRepositorio>();
var scrapingService = scope.ServiceProvider.GetRequiredService<ScrapingService>();
var hubContext = scope.ServiceProvider.GetRequiredService<IHubContext<TitularesHub>>();
// Obtener estos valores desde la configuración
int cantidadAObtener = configActual.CantidadTitularesAScrapear;
int limiteTotalEnDb = configActual.LimiteTotalEnDb;
// 1. Obtener los últimos titulares de la web
var articulosScrapeados = await scrapingService.ObtenerUltimosTitulares(cantidadAObtener);
if (articulosScrapeados.Any())
{
// 2. Sincronizar con la base de datos
await repositorio.SincronizarDesdeScraping(articulosScrapeados, limiteTotalEnDb);
_logger.LogInformation("Sincronización con la base de datos completada.");
// 3. Notificar a todos los clientes a través de SignalR
var titularesActualizados = await repositorio.ObtenerTodosAsync();
await hubContext.Clients.All.SendAsync("TitularesActualizados", titularesActualizados, stoppingToken);
_logger.LogInformation("Notificación enviada a los clientes.");
}
else
{
_logger.LogWarning("No se encontraron artículos en el scraping.");
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Ocurrió un error durante el proceso de scraping.");
}
var intervaloEnMinutos = configActual.IntervaloMinutos;
_logger.LogInformation("Proceso en espera por {minutos} minutos.", intervaloEnMinutos);
await Task.Delay(TimeSpan.FromMinutes(intervaloEnMinutos), stoppingToken);
}
}
}

View File

@@ -0,0 +1,6 @@
{
"RutaCsv": "C:\\temp\\titulares.csv",
"IntervaloMinutos": 5,
"CantidadTitularesAScrapear": 5,
"LimiteTotalEnDb": 50
}