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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user