Fase 5: Implementada la configuración dinámica. Implementado el scraping web.
This commit is contained in:
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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ó
|
||||
}
|
||||
}
|
||||
}
|
||||
9
backend/src/Titulares.Api/Models/ArticuloScrapeado.cs
Normal file
9
backend/src/Titulares.Api/Models/ArticuloScrapeado.cs
Normal 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; }
|
||||
}
|
||||
11
backend/src/Titulares.Api/Models/ConfiguracionApp.cs
Normal file
11
backend/src/Titulares.Api/Models/ConfiguracionApp.cs
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
80
backend/src/Titulares.Api/Services/ScrapingService.cs
Normal file
80
backend/src/Titulares.Api/Services/ScrapingService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
77
backend/src/Titulares.Api/Workers/ProcesoScrapingWorker.cs
Normal file
77
backend/src/Titulares.Api/Workers/ProcesoScrapingWorker.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
6
backend/src/Titulares.Api/configuracion.json
Normal file
6
backend/src/Titulares.Api/configuracion.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"RutaCsv": "C:\\temp\\titulares.csv",
|
||||
"IntervaloMinutos": 5,
|
||||
"CantidadTitularesAScrapear": 5,
|
||||
"LimiteTotalEnDb": 50
|
||||
}
|
||||
Reference in New Issue
Block a user