diff --git a/backend/src/Titulares.Api/Controllers/ConfiguracionController.cs b/backend/src/Titulares.Api/Controllers/ConfiguracionController.cs new file mode 100644 index 0000000..3250abb --- /dev/null +++ b/backend/src/Titulares.Api/Controllers/ConfiguracionController.cs @@ -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 _configuracionMonitor; + private readonly string _configFilePath = "configuracion.json"; + + public ConfiguracionController(IOptionsMonitor configuracionMonitor) + { + _configuracionMonitor = configuracionMonitor; + } + + [HttpGet] + public IActionResult ObtenerConfiguracion() + { + // IOptionsMonitor siempre nos da el valor más reciente. + return Ok(_configuracionMonitor.CurrentValue); + } + + [HttpPost] + public async Task 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}"); + } + } +} \ No newline at end of file diff --git a/backend/src/Titulares.Api/Data/TitularRepositorio.cs b/backend/src/Titulares.Api/Data/TitularRepositorio.cs index 483e5cc..710c9bf 100644 --- a/backend/src/Titulares.Api/Data/TitularRepositorio.cs +++ b/backend/src/Titulares.Api/Data/TitularRepositorio.cs @@ -82,4 +82,70 @@ public class TitularRepositorio return false; } } + + public async Task SincronizarDesdeScraping(List 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( + "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ó + } + } } \ No newline at end of file diff --git a/backend/src/Titulares.Api/Models/ArticuloScrapeado.cs b/backend/src/Titulares.Api/Models/ArticuloScrapeado.cs new file mode 100644 index 0000000..4dcb0d3 --- /dev/null +++ b/backend/src/Titulares.Api/Models/ArticuloScrapeado.cs @@ -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; } +} \ No newline at end of file diff --git a/backend/src/Titulares.Api/Models/ConfiguracionApp.cs b/backend/src/Titulares.Api/Models/ConfiguracionApp.cs new file mode 100644 index 0000000..89f644b --- /dev/null +++ b/backend/src/Titulares.Api/Models/ConfiguracionApp.cs @@ -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; +} \ No newline at end of file diff --git a/backend/src/Titulares.Api/Program.cs b/backend/src/Titulares.Api/Program.cs index 285ef56..cb82ba5 100644 --- a/backend/src/Titulares.Api/Program.cs +++ b/backend/src/Titulares.Api/Program.cs @@ -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(builder.Configuration); + // Añadimos nuestro repositorio personalizado builder.Services.AddSingleton(); +builder.Services.AddScoped(); // 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(); + // Añadimos los servicios de autorización (necesario para app.UseAuthorization) builder.Services.AddAuthorization(); diff --git a/backend/src/Titulares.Api/Services/ScrapingService.cs b/backend/src/Titulares.Api/Services/ScrapingService.cs new file mode 100644 index 0000000..36b2151 --- /dev/null +++ b/backend/src/Titulares.Api/Services/ScrapingService.cs @@ -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> ObtenerUltimosTitulares(int cantidad) + { + var articulos = new List(); + 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(); + + foreach (var nodoEnlace in nodosDeEnlace) + { + var urlRelativa = nodoEnlace.GetAttributeValue("href", string.Empty); + if (string.IsNullOrEmpty(urlRelativa) || urlRelativa == "#" || urlsProcesadas.Contains(urlRelativa)) + { + continue; + } + + // Buscamos un

O un

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; + } +} \ No newline at end of file diff --git a/backend/src/Titulares.Api/Workers/ProcesoScrapingWorker.cs b/backend/src/Titulares.Api/Workers/ProcesoScrapingWorker.cs new file mode 100644 index 0000000..83d786b --- /dev/null +++ b/backend/src/Titulares.Api/Workers/ProcesoScrapingWorker.cs @@ -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 _logger; + private readonly IServiceProvider _serviceProvider; + private readonly IOptionsMonitor _configuracion; + + public ProcesoScrapingWorker(ILogger logger, IServiceProvider serviceProvider, IOptionsMonitor 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(); + var scrapingService = scope.ServiceProvider.GetRequiredService(); + var hubContext = scope.ServiceProvider.GetRequiredService>(); + + // 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); + } + } +} \ No newline at end of file diff --git a/backend/src/Titulares.Api/configuracion.json b/backend/src/Titulares.Api/configuracion.json new file mode 100644 index 0000000..b2369f1 --- /dev/null +++ b/backend/src/Titulares.Api/configuracion.json @@ -0,0 +1,6 @@ +{ + "RutaCsv": "C:\\temp\\titulares.csv", + "IntervaloMinutos": 5, + "CantidadTitularesAScrapear": 5, + "LimiteTotalEnDb": 50 +} \ No newline at end of file diff --git a/frontend/src/components/FormularioConfiguracion.tsx b/frontend/src/components/FormularioConfiguracion.tsx index a3a3303..53d63f0 100644 --- a/frontend/src/components/FormularioConfiguracion.tsx +++ b/frontend/src/components/FormularioConfiguracion.tsx @@ -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(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) => { + 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 ; + } + + if (!config) { + return No se pudo cargar la configuración. + } + return ( - - Configuración - - + Configuración + - - + + + {success && ¡Guardado!} + diff --git a/frontend/src/services/apiService.ts b/frontend/src/services/apiService.ts index 1af7f20..52035a4 100644 --- a/frontend/src/services/apiService.ts +++ b/frontend/src/services/apiService.ts @@ -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 => { await apiClient.put('/titulares/reordenar', payload); +}; + +export const obtenerConfiguracion = async (): Promise => { + const response = await apiClient.get('/configuracion'); + return response.data; +}; + +export const guardarConfiguracion = async (config: Configuracion): Promise => { + await apiClient.post('/configuracion', config); }; \ No newline at end of file diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b722db0..d4a36f6 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; } \ No newline at end of file