Versión 1.0: Aplicación funcionalmente completa con todas las características principales implementadas.

This commit is contained in:
2025-10-29 11:36:20 -03:00
parent 5b3dede4d5
commit 3fbb254ac3
19 changed files with 587 additions and 250 deletions

View File

@@ -10,12 +10,17 @@ namespace Titulares.Api.Controllers;
[Route("api/[controller]")]
public class AccionesController : ControllerBase
{
private readonly TitularRepositorio _repositorio;
private readonly TitularRepositorio _titularRepositorio;
private readonly ConfiguracionRepositorio _configRepositorio;
private readonly CsvService _csvService;
public AccionesController(TitularRepositorio repositorio, CsvService csvService)
public AccionesController(
TitularRepositorio titularRepositorio,
ConfiguracionRepositorio configRepositorio,
CsvService csvService)
{
_repositorio = repositorio;
_titularRepositorio = titularRepositorio;
_configRepositorio = configRepositorio;
_csvService = csvService;
}
@@ -24,8 +29,13 @@ public class AccionesController : ControllerBase
{
try
{
var titulares = await _repositorio.ObtenerTodosAsync();
await _csvService.GenerarCsvAsync(titulares);
// Obtenemos tanto los titulares como la configuración más reciente de la DB
var titulares = await _titularRepositorio.ObtenerTodosAsync();
var config = await _configRepositorio.ObtenerAsync();
// Pasamos ambos al servicio
await _csvService.GenerarCsvAsync(titulares, config);
return Ok(new { message = "CSV generado manualmente con éxito." });
}
catch (Exception ex)

View File

@@ -1,48 +1,31 @@
// backend/src/Titulares.Api/Controllers/ConfiguracionController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using System.Text.Json;
using Titulares.Api.Data;
using Titulares.Api.Models;
[ApiController]
[Route("api/[controller]")]
public class ConfiguracionController : ControllerBase
{
private readonly IOptionsMonitor<ConfiguracionApp> _configuracionMonitor;
private readonly string _configFilePath = "configuracion.json";
private readonly ConfiguracionRepositorio _repositorio;
public ConfiguracionController(IOptionsMonitor<ConfiguracionApp> configuracionMonitor)
public ConfiguracionController(ConfiguracionRepositorio repositorio)
{
_configuracionMonitor = configuracionMonitor;
_repositorio = repositorio;
}
[HttpGet]
public IActionResult ObtenerConfiguracion()
public async Task<IActionResult> ObtenerConfiguracion()
{
// IOptionsMonitor siempre nos da el valor más reciente.
return Ok(_configuracionMonitor.CurrentValue);
var config = await _repositorio.ObtenerAsync();
return Ok(config);
}
[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}");
}
await _repositorio.ActualizarAsync(nuevaConfiguracion);
return Ok(new { message = "Configuración guardada correctamente." });
}
}

View File

@@ -0,0 +1,35 @@
// backend/src/Titulares.Api/Controllers/EstadoProcesoController.cs
using Microsoft.AspNetCore.Mvc;
using Titulares.Api.Services;
[ApiController]
[Route("api/estado-proceso")]
public class EstadoProcesoController : ControllerBase
{
private readonly EstadoProcesoService _estadoService;
public EstadoProcesoController(EstadoProcesoService estadoService)
{
_estadoService = estadoService;
}
[HttpGet]
public IActionResult ObtenerEstado()
{
return Ok(new { activo = _estadoService.EstaActivo() });
}
[HttpPost]
public IActionResult CambiarEstado([FromBody] CambiarEstadoDto dto)
{
if (dto.Activo)
_estadoService.Activar();
else
_estadoService.Desactivar();
return Ok(new { message = "Estado cambiado." });
}
public class CambiarEstadoDto { public bool Activo { get; set; } }
}

View File

@@ -0,0 +1,40 @@
// backend/src/Titulares.Api/Data/ConfiguracionRepositorio.cs
using Dapper;
using Microsoft.Data.SqlClient;
using Titulares.Api.Models;
namespace Titulares.Api.Data;
public class ConfiguracionRepositorio
{
private readonly string _connectionString;
public ConfiguracionRepositorio(IConfiguration configuration)
{
_connectionString = configuration.GetConnectionString("DefaultConnection")!;
}
public async Task<ConfiguracionApp> ObtenerAsync()
{
using var connection = new SqlConnection(_connectionString);
// Siempre obtenemos la fila con Id = 1
return await connection.QuerySingleAsync<ConfiguracionApp>("SELECT * FROM Configuracion WHERE Id = 1");
}
public async Task<bool> ActualizarAsync(ConfiguracionApp config)
{
using var connection = new SqlConnection(_connectionString);
var sql = @"
UPDATE Configuracion
SET
IntervaloMinutos = @IntervaloMinutos,
CantidadTitularesAScrapear = @CantidadTitularesAScrapear,
RutaCsv = @RutaCsv,
ViñetaPorDefecto = @ViñetaPorDefecto
WHERE Id = 1;
";
var affectedRows = await connection.ExecuteAsync(sql, config);
return affectedRows > 0;
}
}

View File

@@ -101,50 +101,90 @@ public class TitularRepositorio
try
{
// --- PARTE 1: AÑADIR NUEVOS TITULARES ---
var urlsEnDb = (await connection.QueryAsync<string>(
"SELECT UrlFuente FROM Titulares WHERE UrlFuente IS NOT NULL", transaction: transaction))
.ToHashSet();
var articulosNuevos = articulosScrapeados
.Where(a => !urlsEnDb.Contains(a.UrlFuente))
// 1. Obtenemos TODOS los titulares actuales en memoria, ordenados correctamente.
var titularesActuales = (await connection.QueryAsync<Titular>(
"SELECT * FROM Titulares ORDER BY OrdenVisual ASC", transaction: transaction))
.ToList();
if (articulosNuevos.Any())
{
var cantidadNuevos = articulosNuevos.Count;
await connection.ExecuteAsync(
"UPDATE Titulares SET OrdenVisual = OrdenVisual + @cantidadNuevos",
new { cantidadNuevos },
transaction: transaction);
var urlsEnDb = titularesActuales
.Where(t => t.UrlFuente != null)
.Select(t => t.UrlFuente!)
.ToHashSet();
for (int i = 0; i < cantidadNuevos; i++)
// 2. Identificamos los nuevos artículos y los titulares de scraping existentes.
var articulosNuevos = articulosScrapeados.Where(a => !urlsEnDb.Contains(a.UrlFuente)).ToList();
var titularesScrapingExistentes = titularesActuales.Where(t => !t.EsEntradaManual).ToList();
// 3. Creamos una nueva lista combinada en C#
var listaCombinada = new List<Titular>();
// Añadimos los nuevos artículos scrapeados al principio de la lista de scraping
foreach (var articuloNuevo in articulosNuevos)
{
listaCombinada.Add(new Titular
{
var articulo = articulosNuevos[i];
var sqlInsert = @"
INSERT INTO Titulares (Texto, UrlFuente, OrdenVisual, Tipo, Fuente)
VALUES (@Texto, @UrlFuente, @OrdenVisual, 'Scraped', 'eldia.com');
";
await connection.ExecuteAsync(sqlInsert, new { articulo.Texto, articulo.UrlFuente, OrdenVisual = i }, transaction: transaction);
Texto = articuloNuevo.Texto,
UrlFuente = articuloNuevo.UrlFuente,
EsEntradaManual = false,
Tipo = "Scraped",
Fuente = "eldia.com"
});
}
// Añadimos los viejos artículos de scraping después de los nuevos
listaCombinada.AddRange(titularesScrapingExistentes);
// 4. Tomamos solo la cantidad de titulares de scraping que necesitamos.
var scrapingFinal = listaCombinada.Take(cantidadALimitar).ToList();
// 5. Re-insertamos los titulares manuales en la lista final, preservando su orden relativo.
var listaFinal = new List<Titular>();
var manuales = titularesActuales.Where(t => t.EsEntradaManual).OrderBy(t => t.OrdenVisual).ToList();
var scraping = scrapingFinal;
// Reconstruimos la lista completa, titular por titular, para preservar el orden
int punteroManual = 0;
int punteroScraping = 0;
for (int i = 0; i < titularesActuales.Count; i++)
{
var titularOriginal = titularesActuales[i];
if (titularOriginal.EsEntradaManual)
{
if (punteroManual < manuales.Count)
{
listaFinal.Add(manuales[punteroManual]);
punteroManual++;
}
}
else
{
if (punteroScraping < scraping.Count)
{
listaFinal.Add(scraping[punteroScraping]);
punteroScraping++;
}
}
}
// --- PARTE 2: PURGAR LOS SOBRANTES ---
var sqlDelete = @"
DELETE FROM Titulares
WHERE Id IN (
-- Seleccionamos los IDs de los titulares que queremos borrar.
-- Son aquellos que no son manuales, ordenados por antigüedad (los más nuevos primero),
-- y nos saltamos los primeros 'N' que queremos conservar.
SELECT Id
FROM Titulares
WHERE EsEntradaManual = 0
ORDER BY OrdenVisual ASC
OFFSET @Limite ROWS
);
";
// Usamos cantidadALimitar como el límite de cuántos conservar.
await connection.ExecuteAsync(sqlDelete, new { Limite = cantidadALimitar }, transaction: transaction);
// Si sobraron manuales o de scraping (por cambio de cantidad), los añadimos al final.
while (punteroScraping < scraping.Count) { listaFinal.Add(scraping[punteroScraping++]); }
while (punteroManual < manuales.Count) { listaFinal.Add(manuales[punteroManual++]); }
// 6. Borramos TODOS los titulares actuales de la DB.
await connection.ExecuteAsync("DELETE FROM Titulares", transaction: transaction);
// 7. Re-insertamos la lista final y perfectamente ordenada, asignando el nuevo OrdenVisual.
for (int i = 0; i < listaFinal.Count; i++)
{
var titular = listaFinal[i];
titular.OrdenVisual = i; // Asignamos el orden correcto y contiguo.
var sqlInsert = @"
INSERT INTO Titulares (Texto, UrlFuente, ModificadoPorUsuario, EsEntradaManual, OrdenVisual, Tipo, Fuente, Viñeta)
VALUES (@Texto, @UrlFuente, @ModificadoPorUsuario, @EsEntradaManual, @OrdenVisual, @Tipo, @Fuente, @Viñeta);
";
await connection.ExecuteAsync(sqlInsert, titular, transaction: transaction);
}
transaction.Commit();
}

View File

@@ -7,5 +7,6 @@ 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;
public bool ScrapingActivo { get; set; } = false;
public string ViñetaPorDefecto { get; set; } = "•";
}

View File

@@ -20,6 +20,8 @@ builder.Services.Configure<ConfiguracionApp>(builder.Configuration);
builder.Services.AddSingleton<TitularRepositorio>();
builder.Services.AddScoped<ScrapingService>();
builder.Services.AddScoped<CsvService>();
builder.Services.AddSingleton<ConfiguracionRepositorio>();
builder.Services.AddSingleton<EstadoProcesoService>();
// Añadimos la política de CORS
builder.Services.AddCors(options =>

View File

@@ -2,8 +2,8 @@
using CsvHelper;
using CsvHelper.Configuration;
using Microsoft.Extensions.Options;
using System.Globalization;
using System.Text;
using Titulares.Api.Models;
namespace Titulares.Api.Services;
@@ -11,17 +11,13 @@ namespace Titulares.Api.Services;
public class CsvService
{
private readonly ILogger<CsvService> _logger;
private readonly IOptionsMonitor<ConfiguracionApp> _configuracion;
public CsvService(ILogger<CsvService> logger, IOptionsMonitor<ConfiguracionApp> configuracion)
public CsvService(ILogger<CsvService> logger)
{
_logger = logger;
_configuracion = configuracion;
}
public async Task GenerarCsvAsync(IEnumerable<Titular> titulares)
public async Task GenerarCsvAsync(IEnumerable<Titular> titulares, ConfiguracionApp config)
{
var rutaArchivo = _configuracion.CurrentValue.RutaCsv;
var rutaArchivo = config.RutaCsv;
_logger.LogInformation("Iniciando generación de CSV en: {Ruta}", rutaArchivo);
try
@@ -32,16 +28,15 @@ public class CsvService
Directory.CreateDirectory(directorio);
}
var config = new CsvConfiguration(CultureInfo.InvariantCulture)
var csvConfig = new CsvConfiguration(CultureInfo.InvariantCulture)
{
Delimiter = ";",
HasHeaderRecord = false,
// Esta linea es para que nunca se ponga comillas en ningún campo.
ShouldQuote = args => false,
};
await using var writer = new StreamWriter(rutaArchivo);
await using var csv = new CsvWriter(writer, config);
await using var writer = new StreamWriter(rutaArchivo, false, Encoding.BigEndianUnicode);
await using var csv = new CsvWriter(writer, csvConfig);
csv.WriteField("Text");
csv.WriteField("Bullet");
@@ -50,11 +45,11 @@ public class CsvService
foreach (var titular in titulares)
{
csv.WriteField(titular.Texto);
csv.WriteField(titular.Viñeta ?? "•");
csv.WriteField(titular.Viñeta ?? config.ViñetaPorDefecto);
await csv.NextRecordAsync();
}
_logger.LogInformation("CSV generado exitosamente con formato simplificado.");
_logger.LogInformation("CSV generado exitosamente con codificación UTF-16 BE BOM.");
}
catch (Exception ex)
{

View File

@@ -0,0 +1,27 @@
// backend/src/Titulares.Api/Services/EstadoProcesoService.cs
namespace Titulares.Api.Services;
public class EstadoProcesoService
{
private volatile bool _estaActivo = false;
// 1. Definimos un evento público al que otros servicios pueden suscribirse.
public event Action? OnStateChanged;
public bool EstaActivo() => _estaActivo;
public void Activar()
{
_estaActivo = true;
// 2. Disparamos el evento para notificar a los suscriptores.
OnStateChanged?.Invoke();
}
public void Desactivar()
{
_estaActivo = false;
// 3. Disparamos el evento también al desactivar.
OnStateChanged?.Invoke();
}
}

View File

@@ -4,8 +4,6 @@ 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;
@@ -13,22 +11,23 @@ public class ProcesoScrapingWorker : BackgroundService, IDisposable
{
private readonly ILogger<ProcesoScrapingWorker> _logger;
private readonly IServiceProvider _serviceProvider;
private readonly IOptionsMonitor<ConfiguracionApp> _configuracion;
private readonly IDisposable? _optionsReloadToken;
private readonly EstadoProcesoService _estadoService;
private CancellationTokenSource? _delayCts;
public ProcesoScrapingWorker(ILogger<ProcesoScrapingWorker> logger, IServiceProvider serviceProvider, IOptionsMonitor<ConfiguracionApp> configuracion)
public ProcesoScrapingWorker(ILogger<ProcesoScrapingWorker> logger, IServiceProvider serviceProvider, EstadoProcesoService estadoService)
{
_logger = logger;
_serviceProvider = serviceProvider;
_configuracion = configuracion;
_optionsReloadToken = _configuracion.OnChange(OnConfigurationChanged);
_estadoService = estadoService;
// Nos suscribimos al evento del servicio de estado
_estadoService.OnStateChanged += OnEstadoProcesoChanged;
}
private void OnConfigurationChanged(ConfiguracionApp newConfig)
// Este método se ejecutará cuando el evento OnStateChanged se dispare
private void OnEstadoProcesoChanged()
{
_logger.LogInformation("La configuración ha cambiado. Interrumpiendo la espera actual para aplicar los nuevos ajustes.");
_logger.LogInformation("El estado del proceso ha cambiado. Interrumpiendo la espera actual.");
_delayCts?.Cancel();
}
@@ -36,58 +35,68 @@ public class ProcesoScrapingWorker : BackgroundService, IDisposable
{
while (!stoppingToken.IsCancellationRequested)
{
var configActual = _configuracion.CurrentValue;
_logger.LogInformation("Iniciando ciclo de scraping con cantidad: {cantidad}", configActual.CantidadTitularesAScrapear);
try
using (var scope = _serviceProvider.CreateScope())
{
using (var scope = _serviceProvider.CreateScope())
var configRepositorio = scope.ServiceProvider.GetRequiredService<ConfiguracionRepositorio>();
var config = await configRepositorio.ObtenerAsync();
if (!_estadoService.EstaActivo())
{
_logger.LogInformation("El scraping está desactivado. Entrando en modo de espera.");
await EsperarIntervaloAsync(config.IntervaloMinutos, stoppingToken);
continue;
}
_logger.LogInformation("Iniciando ciclo de scraping con cantidad: {cantidad}", config.CantidadTitularesAScrapear);
try
{
var repositorio = scope.ServiceProvider.GetRequiredService<TitularRepositorio>();
var scrapingService = scope.ServiceProvider.GetRequiredService<ScrapingService>();
var hubContext = scope.ServiceProvider.GetRequiredService<IHubContext<TitularesHub>>();
var csvService = scope.ServiceProvider.GetRequiredService<CsvService>();
int cantidadTitulares = configActual.CantidadTitularesAScrapear;
var articulosScrapeados = await scrapingService.ObtenerUltimosTitulares(cantidadTitulares);
await repositorio.SincronizarDesdeScraping(articulosScrapeados, cantidadTitulares);
_logger.LogInformation("Sincronización con la base de datos completada.");
var articulosScrapeados = await scrapingService.ObtenerUltimosTitulares(config.CantidadTitularesAScrapear);
await repositorio.SincronizarDesdeScraping(articulosScrapeados, config.CantidadTitularesAScrapear);
var titularesActualizados = await repositorio.ObtenerTodosAsync();
await hubContext.Clients.All.SendAsync("TitularesActualizados", titularesActualizados, stoppingToken);
_logger.LogInformation("Notificación enviada a los clientes.");
await csvService.GenerarCsvAsync(titularesActualizados);
await csvService.GenerarCsvAsync(titularesActualizados, config);
}
catch (Exception ex)
{
_logger.LogError(ex, "Ocurrió un error durante el proceso de 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);
try
{
_delayCts = new CancellationTokenSource();
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, _delayCts.Token);
await Task.Delay(TimeSpan.FromMinutes(intervaloEnMinutos), linkedCts.Token);
}
catch (TaskCanceledException)
{
_logger.LogInformation("La espera fue interrumpida. Reiniciando el ciclo.");
}
finally
{
_delayCts?.Dispose();
_delayCts = null;
await EsperarIntervaloAsync(config.IntervaloMinutos, stoppingToken);
}
}
}
private async Task EsperarIntervaloAsync(int minutos, CancellationToken stoppingToken)
{
_logger.LogInformation("Proceso en espera por {minutos} minutos.", minutos);
try
{
_delayCts = new CancellationTokenSource();
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, _delayCts.Token);
await Task.Delay(TimeSpan.FromMinutes(minutos), linkedCts.Token);
}
catch (TaskCanceledException)
{
_logger.LogInformation("La espera fue interrumpida. Reiniciando el ciclo.");
}
finally
{
_delayCts?.Dispose();
_delayCts = null;
}
}
// Es crucial desuscribirse del evento para evitar fugas de memoria
public override void Dispose()
{
_optionsReloadToken?.Dispose();
_estadoService.OnStateChanged -= OnEstadoProcesoChanged;
_delayCts?.Dispose();
base.Dispose();
}

View File

@@ -1,5 +1,6 @@
{
"RutaCsv": "C:\\temp\\titulares.csv",
"IntervaloMinutos": 5,
"CantidadTitularesAScrapear": 5
"IntervaloMinutos": 15,
"CantidadTitularesAScrapear": 4,
"ScrapingActivo": false
}