Feat: Edición y Manejo de Titulares, entre otros.
This commit is contained in:
@@ -47,9 +47,13 @@ public class TitularesController : ControllerBase
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> Actualizar(int id, [FromBody] ActualizarTitularDto titularDto)
|
||||
{
|
||||
var resultado = await _repositorio.ActualizarTextoAsync(id, titularDto);
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
var resultado = await _repositorio.ActualizarTitularAsync(id, titularDto);
|
||||
if (!resultado) return NotFound();
|
||||
await NotificarCambios(); // Notificamos después de actualizar
|
||||
await NotificarCambios();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
|
||||
@@ -39,15 +39,25 @@ public class TitularRepositorio
|
||||
return await connection.ExecuteScalarAsync<int>(sql, new { titularDto.Texto, OrdenVisual = nuevoOrden });
|
||||
}
|
||||
|
||||
public async Task<bool> ActualizarTextoAsync(int id, ActualizarTitularDto titularDto)
|
||||
public async Task<bool> ActualizarTitularAsync(int id, ActualizarTitularDto titularDto)
|
||||
{
|
||||
using var connection = CreateConnection();
|
||||
// La consulta SQL actualiza Texto, Viñeta y las banderas de estado
|
||||
var sql = @"
|
||||
UPDATE Titulares
|
||||
SET Texto = @Texto, ModificadoPorUsuario = 1, Tipo = 'Edited'
|
||||
SET
|
||||
Texto = @Texto,
|
||||
Viñeta = @Viñeta,
|
||||
ModificadoPorUsuario = 1,
|
||||
Tipo = 'Edited'
|
||||
WHERE Id = @Id;
|
||||
";
|
||||
var affectedRows = await connection.ExecuteAsync(sql, new { titularDto.Texto, Id = id });
|
||||
var affectedRows = await connection.ExecuteAsync(sql, new
|
||||
{
|
||||
titularDto.Texto,
|
||||
titularDto.Viñeta,
|
||||
Id = id
|
||||
});
|
||||
return affectedRows > 0;
|
||||
}
|
||||
|
||||
@@ -83,7 +93,7 @@ public class TitularRepositorio
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SincronizarDesdeScraping(List<ArticuloScrapeado> articulosScrapeados, int limiteTotal)
|
||||
public async Task SincronizarDesdeScraping(List<ArticuloScrapeado> articulosScrapeados, int cantidadALimitar)
|
||||
{
|
||||
using var connection = CreateConnection();
|
||||
await connection.OpenAsync();
|
||||
@@ -91,12 +101,11 @@ public class TitularRepositorio
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Obtener las URLs que ya tenemos en la base de datos
|
||||
// --- PARTE 1: AÑADIR NUEVOS TITULARES ---
|
||||
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();
|
||||
@@ -104,48 +113,45 @@ public class TitularRepositorio
|
||||
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');
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw; // Relanzamos la excepción para que el worker sepa que algo falló
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,5 @@ 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 int LimiteTotalEnDb { get; set; } = 50;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ public class CrearTitularDto
|
||||
public class ActualizarTitularDto
|
||||
{
|
||||
public required string Texto { get; set; }
|
||||
public string? Viñeta { get; set; }
|
||||
}
|
||||
|
||||
// DTO para el reordenamiento
|
||||
|
||||
@@ -26,53 +26,35 @@ public class CsvService
|
||||
|
||||
try
|
||||
{
|
||||
// La cláusula Where asegura que los Encabezados no sean nulos o vacíos.
|
||||
var grupos = titulares
|
||||
.Where(t => !string.IsNullOrEmpty(t.Encabezado))
|
||||
.GroupBy(t => t.Encabezado);
|
||||
var directorio = Path.GetDirectoryName(rutaArchivo);
|
||||
if (directorio != null)
|
||||
{
|
||||
Directory.CreateDirectory(directorio);
|
||||
}
|
||||
|
||||
var config = 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);
|
||||
|
||||
// Escribimos la cabecera principal
|
||||
csv.WriteField("Text");
|
||||
csv.WriteField("Heading");
|
||||
csv.WriteField("Value");
|
||||
csv.WriteField("Up");
|
||||
csv.WriteField("Down");
|
||||
csv.WriteField("Change");
|
||||
csv.WriteField("Bullet");
|
||||
await csv.NextRecordAsync();
|
||||
|
||||
foreach (var grupo in grupos)
|
||||
foreach (var titular in titulares)
|
||||
{
|
||||
csv.WriteField("");
|
||||
csv.WriteField($" {grupo.Key!.ToUpper()} ");
|
||||
csv.WriteField("");
|
||||
await csv.NextRecordAsync();
|
||||
|
||||
await csv.NextRecordAsync();
|
||||
|
||||
foreach (var titular in grupo)
|
||||
{
|
||||
csv.WriteField(titular.Texto);
|
||||
csv.WriteField(""); // Heading
|
||||
csv.WriteField(""); // Value
|
||||
csv.WriteField(""); // Up
|
||||
csv.WriteField(""); // Down
|
||||
csv.WriteField(""); // Change
|
||||
csv.WriteField(titular.Viñeta ?? "•");
|
||||
await csv.NextRecordAsync();
|
||||
}
|
||||
|
||||
csv.WriteField(titular.Texto);
|
||||
csv.WriteField(titular.Viñeta ?? "•");
|
||||
await csv.NextRecordAsync();
|
||||
}
|
||||
_logger.LogInformation("CSV generado exitosamente.");
|
||||
|
||||
_logger.LogInformation("CSV generado exitosamente con formato simplificado.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -9,17 +9,27 @@ using Titulares.Api.Models;
|
||||
|
||||
namespace Titulares.Api.Workers;
|
||||
|
||||
public class ProcesoScrapingWorker : BackgroundService
|
||||
public class ProcesoScrapingWorker : BackgroundService, IDisposable
|
||||
{
|
||||
private readonly ILogger<ProcesoScrapingWorker> _logger;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly IOptionsMonitor<ConfiguracionApp> _configuracion;
|
||||
private readonly IDisposable? _optionsReloadToken;
|
||||
|
||||
private CancellationTokenSource? _delayCts;
|
||||
|
||||
public ProcesoScrapingWorker(ILogger<ProcesoScrapingWorker> logger, IServiceProvider serviceProvider, IOptionsMonitor<ConfiguracionApp> configuracion)
|
||||
{
|
||||
_logger = logger;
|
||||
_serviceProvider = serviceProvider;
|
||||
_configuracion = configuracion;
|
||||
_optionsReloadToken = _configuracion.OnChange(OnConfigurationChanged);
|
||||
}
|
||||
|
||||
private void OnConfigurationChanged(ConfiguracionApp newConfig)
|
||||
{
|
||||
_logger.LogInformation("La configuración ha cambiado. Interrumpiendo la espera actual para aplicar los nuevos ajustes.");
|
||||
_delayCts?.Cancel();
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
@@ -27,42 +37,26 @@ public class ProcesoScrapingWorker : BackgroundService
|
||||
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>>();
|
||||
var csvService = scope.ServiceProvider.GetRequiredService<CsvService>();
|
||||
int cantidadTitulares = configActual.CantidadTitularesAScrapear;
|
||||
var articulosScrapeados = await scrapingService.ObtenerUltimosTitulares(cantidadTitulares);
|
||||
|
||||
// Obtener estos valores desde la configuración
|
||||
int cantidadAObtener = configActual.CantidadTitularesAScrapear;
|
||||
int limiteTotalEnDb = configActual.LimiteTotalEnDb;
|
||||
await repositorio.SincronizarDesdeScraping(articulosScrapeados, cantidadTitulares);
|
||||
_logger.LogInformation("Sincronización con la base de datos completada.");
|
||||
|
||||
// 1. Obtener los últimos titulares de la web
|
||||
var articulosScrapeados = await scrapingService.ObtenerUltimosTitulares(cantidadAObtener);
|
||||
|
||||
if (articulosScrapeados.Any())
|
||||
{
|
||||
await repositorio.SincronizarDesdeScraping(articulosScrapeados, limiteTotalEnDb);
|
||||
_logger.LogInformation("Sincronización con la base de datos completada.");
|
||||
|
||||
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);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No se encontraron artículos en el scraping.");
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -72,7 +66,29 @@ public class ProcesoScrapingWorker : BackgroundService
|
||||
|
||||
var intervaloEnMinutos = configActual.IntervaloMinutos;
|
||||
_logger.LogInformation("Proceso en espera por {minutos} minutos.", intervaloEnMinutos);
|
||||
await Task.Delay(TimeSpan.FromMinutes(intervaloEnMinutos), stoppingToken);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
_optionsReloadToken?.Dispose();
|
||||
_delayCts?.Dispose();
|
||||
base.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"RutaCsv": "C:\\temp\\titulares.csv",
|
||||
"IntervaloMinutos": 5,
|
||||
"CantidadTitularesAScrapear": 5,
|
||||
"LimiteTotalEnDb": 50
|
||||
"CantidadTitularesAScrapear": 5
|
||||
}
|
||||
Reference in New Issue
Block a user