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
}

View File

@@ -1,31 +1,82 @@
// frontend/src/components/FormularioConfiguracion.tsx
import { Box, TextField, Button, Paper, Typography } from '@mui/material';
import { useEffect, useState } from 'react';
import { Box, TextField, Button, Paper, Typography, CircularProgress } from '@mui/material';
import type { Configuracion } from '../types';
import * as api from '../services/apiService';
const FormularioConfiguracion = () => {
const [config, setConfig] = useState<Configuracion | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [success, setSuccess] = useState(false);
useEffect(() => {
api.obtenerConfiguracion()
.then(data => {
setConfig(data);
setLoading(false);
})
.catch(err => console.error("Error al cargar configuración", err));
}, []);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (!config) return;
const { name, value } = event.target;
setConfig({
...config,
[name]: name === 'rutaCsv' ? value : Number(value) // Convertir a número si no es la ruta
});
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!config) return;
setSaving(true);
setSuccess(false);
try {
await api.guardarConfiguracion(config);
setSuccess(true);
setTimeout(() => setSuccess(false), 2000); // El mensaje de éxito desaparece después de 2s
} catch (err) {
console.error("Error al guardar configuración", err);
} finally {
setSaving(false);
}
};
if (loading) {
return <CircularProgress />;
}
if (!config) {
return <Typography color="error">No se pudo cargar la configuración.</Typography>
}
return (
<Paper elevation={3} sx={{ padding: 2, marginBottom: 3 }}>
<Typography variant="h6" gutterBottom>
Configuración
</Typography>
<Box component="form" noValidate autoComplete="off">
<Typography variant="h6" gutterBottom>Configuración</Typography>
<Box component="form" onSubmit={handleSubmit}>
<TextField
fullWidth
label="Ruta del archivo CSV"
defaultValue="/var/data/headlines.csv"
variant="outlined"
sx={{ marginBottom: 2 }}
fullWidth name="rutaCsv" label="Ruta del archivo CSV"
value={config.rutaCsv} onChange={handleChange}
variant="outlined" sx={{ marginBottom: 2 }} disabled={saving}
/>
<TextField
fullWidth
label="Intervalo de Actualización (minutos)"
defaultValue={5}
type="number"
variant="outlined"
sx={{ marginBottom: 2 }}
fullWidth name="intervaloMinutos" label="Intervalo de Actualización (minutos)"
value={config.intervaloMinutos} onChange={handleChange}
type="number" variant="outlined" sx={{ marginBottom: 2 }} disabled={saving}
/>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="contained">Guardar Cambios</Button>
<TextField
fullWidth name="cantidadTitularesAScrapear" label="Titulares a Capturar por Ciclo"
value={config.cantidadTitularesAScrapear} onChange={handleChange}
type="number" variant="outlined" sx={{ marginBottom: 2 }} disabled={saving}
/>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}>
{success && <Typography color="success.main" sx={{ mr: 2 }}>¡Guardado!</Typography>}
<Button type="submit" variant="contained" disabled={saving}>
{saving ? <CircularProgress size={24} /> : 'Guardar Cambios'}
</Button>
</Box>
</Box>
</Paper>

View File

@@ -1,7 +1,7 @@
// frontend/src/services/apiService.ts
import axios from 'axios';
import type { Titular } from '../types';
import type { Configuracion, Titular } from '../types';
const API_URL = 'http://localhost:5174/api';
@@ -30,4 +30,13 @@ interface ReordenarPayload {
export const actualizarOrdenTitulares = async (payload: ReordenarPayload[]): Promise<void> => {
await apiClient.put('/titulares/reordenar', payload);
};
export const obtenerConfiguracion = async (): Promise<Configuracion> => {
const response = await apiClient.get('/configuracion');
return response.data;
};
export const guardarConfiguracion = async (config: Configuracion): Promise<void> => {
await apiClient.post('/configuracion', config);
};

View File

@@ -10,4 +10,11 @@ export interface Titular {
fechaCreacion: string;
tipo: 'Scraped' | 'Edited' | 'Manual' | null;
fuente: string | null;
}
export interface Configuracion {
rutaCsv: string;
intervaloMinutos: number;
cantidadTitularesAScrapear: number;
limiteTotalEnDb: number;
}