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;
|
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
|
// backend/src/Titulares.Api/Program.cs
|
||||||
using Titulares.Api.Data;
|
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);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Configuration.AddJsonFile("configuracion.json", optional: false, reloadOnChange: true);
|
||||||
|
|
||||||
// Add services to the container.
|
// Add services to the container.
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
||||||
|
builder.Services.Configure<ConfiguracionApp>(builder.Configuration);
|
||||||
|
|
||||||
// Añadimos nuestro repositorio personalizado
|
// Añadimos nuestro repositorio personalizado
|
||||||
builder.Services.AddSingleton<TitularRepositorio>();
|
builder.Services.AddSingleton<TitularRepositorio>();
|
||||||
|
builder.Services.AddScoped<ScrapingService>();
|
||||||
|
|
||||||
// Añadimos la política de CORS
|
// Añadimos la política de CORS
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
@@ -26,6 +34,8 @@ builder.Services.AddCors(options =>
|
|||||||
|
|
||||||
builder.Services.AddSignalR();
|
builder.Services.AddSignalR();
|
||||||
|
|
||||||
|
builder.Services.AddHostedService<ProcesoScrapingWorker>();
|
||||||
|
|
||||||
// Añadimos los servicios de autorización (necesario para app.UseAuthorization)
|
// Añadimos los servicios de autorización (necesario para app.UseAuthorization)
|
||||||
builder.Services.AddAuthorization();
|
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
|
// frontend/src/components/FormularioConfiguracion.tsx
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Box, TextField, Button, Paper, Typography } from '@mui/material';
|
import { Box, TextField, Button, Paper, Typography, CircularProgress } from '@mui/material';
|
||||||
|
import type { Configuracion } from '../types';
|
||||||
|
import * as api from '../services/apiService';
|
||||||
|
|
||||||
const FormularioConfiguracion = () => {
|
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 (
|
return (
|
||||||
<Paper elevation={3} sx={{ padding: 2, marginBottom: 3 }}>
|
<Paper elevation={3} sx={{ padding: 2, marginBottom: 3 }}>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>Configuración</Typography>
|
||||||
Configuración
|
<Box component="form" onSubmit={handleSubmit}>
|
||||||
</Typography>
|
|
||||||
<Box component="form" noValidate autoComplete="off">
|
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth name="rutaCsv" label="Ruta del archivo CSV"
|
||||||
label="Ruta del archivo CSV"
|
value={config.rutaCsv} onChange={handleChange}
|
||||||
defaultValue="/var/data/headlines.csv"
|
variant="outlined" sx={{ marginBottom: 2 }} disabled={saving}
|
||||||
variant="outlined"
|
|
||||||
sx={{ marginBottom: 2 }}
|
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth name="intervaloMinutos" label="Intervalo de Actualización (minutos)"
|
||||||
label="Intervalo de Actualización (minutos)"
|
value={config.intervaloMinutos} onChange={handleChange}
|
||||||
defaultValue={5}
|
type="number" variant="outlined" sx={{ marginBottom: 2 }} disabled={saving}
|
||||||
type="number"
|
|
||||||
variant="outlined"
|
|
||||||
sx={{ marginBottom: 2 }}
|
|
||||||
/>
|
/>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
<TextField
|
||||||
<Button variant="contained">Guardar Cambios</Button>
|
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>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// frontend/src/services/apiService.ts
|
// frontend/src/services/apiService.ts
|
||||||
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { Titular } from '../types';
|
import type { Configuracion, Titular } from '../types';
|
||||||
|
|
||||||
const API_URL = 'http://localhost:5174/api';
|
const API_URL = 'http://localhost:5174/api';
|
||||||
|
|
||||||
@@ -31,3 +31,12 @@ interface ReordenarPayload {
|
|||||||
export const actualizarOrdenTitulares = async (payload: ReordenarPayload[]): Promise<void> => {
|
export const actualizarOrdenTitulares = async (payload: ReordenarPayload[]): Promise<void> => {
|
||||||
await apiClient.put('/titulares/reordenar', payload);
|
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);
|
||||||
|
};
|
||||||
@@ -11,3 +11,10 @@ export interface Titular {
|
|||||||
tipo: 'Scraped' | 'Edited' | 'Manual' | null;
|
tipo: 'Scraped' | 'Edited' | 'Manual' | null;
|
||||||
fuente: string | null;
|
fuente: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Configuracion {
|
||||||
|
rutaCsv: string;
|
||||||
|
intervaloMinutos: number;
|
||||||
|
cantidadTitularesAScrapear: number;
|
||||||
|
limiteTotalEnDb: number;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user