diff --git a/src/Mercados.Infrastructure/DataFetchers/IDataFetcher.cs b/src/Mercados.Infrastructure/DataFetchers/IDataFetcher.cs new file mode 100644 index 0000000..a44726e --- /dev/null +++ b/src/Mercados.Infrastructure/DataFetchers/IDataFetcher.cs @@ -0,0 +1,19 @@ +namespace Mercados.Infrastructure.DataFetchers +{ + /// + /// Define la interfaz para un servicio que obtiene datos de una fuente externa. + /// + public interface IDataFetcher + { + /// + /// Obtiene el nombre único de la fuente de datos. + /// + string SourceName { get; } + + /// + /// Ejecuta el proceso de obtención, transformación y guardado de datos. + /// + /// Una tupla indicando si la operación fue exitosa y un mensaje de estado. + Task<(bool Success, string Message)> FetchDataAsync(); + } +} \ No newline at end of file diff --git a/src/Mercados.Infrastructure/DataFetchers/MercadoAgroFetcher.cs b/src/Mercados.Infrastructure/DataFetchers/MercadoAgroFetcher.cs new file mode 100644 index 0000000..b6ad239 --- /dev/null +++ b/src/Mercados.Infrastructure/DataFetchers/MercadoAgroFetcher.cs @@ -0,0 +1,160 @@ +using AngleSharp; +using Mercados.Core.Entities; +using Mercados.Infrastructure.Persistence.Repositories; +using Microsoft.Extensions.Logging; +using System.Globalization; + +namespace Mercados.Infrastructure.DataFetchers +{ + public class MercadoAgroFetcher : IDataFetcher + { + public string SourceName => "MercadoAgroganadero"; + private const string DataUrl = "https://www.mercadoagroganadero.com.ar/dll/hacienda6.dll/haciinfo000225"; + + private readonly IHttpClientFactory _httpClientFactory; + private readonly ICotizacionGanadoRepository _cotizacionRepository; + private readonly IFuenteDatoRepository _fuenteDatoRepository; + private readonly ILogger _logger; + + public MercadoAgroFetcher( + IHttpClientFactory httpClientFactory, + ICotizacionGanadoRepository cotizacionRepository, + IFuenteDatoRepository fuenteDatoRepository, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _cotizacionRepository = cotizacionRepository; + _fuenteDatoRepository = fuenteDatoRepository; + _logger = logger; + } + + public async Task<(bool Success, string Message)> FetchDataAsync() + { + _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); + + try + { + var htmlContent = await GetHtmlContentAsync(); + if (string.IsNullOrEmpty(htmlContent)) + { + return (false, "No se pudo obtener el contenido HTML."); + } + + var cotizaciones = ParseHtmlToEntities(htmlContent); + if (!cotizaciones.Any()) + { + return (false, "No se encontraron cotizaciones válidas en el HTML."); + } + + await _cotizacionRepository.GuardarMuchosAsync(cotizaciones); + await UpdateSourceInfoAsync(); + + _logger.LogInformation("Fetch para {SourceName} completado exitosamente. Se guardaron {Count} registros.", SourceName, cotizaciones.Count); + return (true, $"Proceso completado. Se guardaron {cotizaciones.Count} registros."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Ocurrió un error durante el fetch para {SourceName}.", SourceName); + return (false, $"Error: {ex.Message}"); + } + } + + private async Task GetHtmlContentAsync() + { + var client = _httpClientFactory.CreateClient(); + // Es importante simular un navegador para evitar bloqueos. + client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"); + + var response = await client.GetAsync(DataUrl); + response.EnsureSuccessStatusCode(); + // El sitio usa una codificación específica, hay que decodificarla correctamente. + var stream = await response.Content.ReadAsStreamAsync(); + using var reader = new StreamReader(stream, System.Text.Encoding.GetEncoding("windows-1252")); + return await reader.ReadToEndAsync(); + } + + private List ParseHtmlToEntities(string html) + { + var config = Configuration.Default; + var context = BrowsingContext.New(config); + var document = context.OpenAsync(req => req.Content(html)).Result; + + var tabla = document.QuerySelector("table.table.table-striped"); + if (tabla == null) + { + _logger.LogWarning("No se encontró la tabla de cotizaciones en el HTML."); + return new List(); + } + + var cotizaciones = new List(); + // Omitimos las primeras 2 filas (cabeceras) y las últimas 2 (totales/pies). + var filas = tabla.QuerySelectorAll("tr").Skip(2).SkipLast(2); + + foreach (var fila in filas) + { + var celdas = fila.QuerySelectorAll("td").Select(c => c.TextContent.Trim()).ToList(); + + if (celdas.Count < 12) continue; // Fila inválida + + try + { + var cotizacion = new CotizacionGanado + { + Categoria = celdas[1], + Especificaciones = $"{celdas[2]} - {celdas[3]}", + Maximo = ParseDecimal(celdas[4]), + Minimo = ParseDecimal(celdas[5]), + Promedio = ParseDecimal(celdas[6]), + Mediano = ParseDecimal(celdas[7]), + Cabezas = ParseInt(celdas[8]), + KilosTotales = ParseInt(celdas[9]), + KilosPorCabeza = ParseInt(celdas[10]), + ImporteTotal = ParseDecimal(celdas[11]), + FechaRegistro = DateTime.UtcNow + }; + cotizaciones.Add(cotizacion); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "No se pudo parsear una fila de la tabla. Contenido: {RowContent}", string.Join(" | ", celdas)); + } + } + + return cotizaciones; + } + + private async Task UpdateSourceInfoAsync() + { + var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); + if (fuente == null) + { + await _fuenteDatoRepository.CrearAsync(new FuenteDato + { + Nombre = SourceName, + Url = DataUrl, + UltimaEjecucionExitosa = DateTime.UtcNow + }); + } + else + { + fuente.Url = DataUrl; + fuente.UltimaEjecucionExitosa = DateTime.UtcNow; + await _fuenteDatoRepository.ActualizarAsync(fuente); + } + } + + // --- Funciones de Ayuda para Parseo --- + private readonly CultureInfo _cultureInfo = new CultureInfo("es-AR"); + private decimal ParseDecimal(string value) + { + // El sitio usa '.' como separador de miles y ',' como decimal. + // Primero quitamos el de miles, luego reemplazamos la coma decimal por un punto. + var cleanValue = value.Replace("$", "").Replace(".", "").Trim(); + return decimal.Parse(cleanValue, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, _cultureInfo); + } + private int ParseInt(string value) + { + return int.Parse(value.Replace(".", ""), CultureInfo.InvariantCulture); + } + } +} \ No newline at end of file diff --git a/src/Mercados.Infrastructure/Mercados.Infrastructure.csproj b/src/Mercados.Infrastructure/Mercados.Infrastructure.csproj index d982fbc..37d0f0f 100644 --- a/src/Mercados.Infrastructure/Mercados.Infrastructure.csproj +++ b/src/Mercados.Infrastructure/Mercados.Infrastructure.csproj @@ -5,6 +5,7 @@ + diff --git a/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionGanadoRepository.cs b/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionGanadoRepository.cs new file mode 100644 index 0000000..85e1f5c --- /dev/null +++ b/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionGanadoRepository.cs @@ -0,0 +1,34 @@ +using Dapper; +using Mercados.Core.Entities; +using System.Data; + +namespace Mercados.Infrastructure.Persistence.Repositories +{ + public class CotizacionGanadoRepository : ICotizacionGanadoRepository + { + private readonly IDbConnectionFactory _connectionFactory; + + public CotizacionGanadoRepository(IDbConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task GuardarMuchosAsync(IEnumerable cotizaciones) + { + using IDbConnection connection = _connectionFactory.CreateConnection(); + + // Dapper puede insertar una colección de objetos de una sola vez, ¡muy eficiente! + const string sql = @" + INSERT INTO CotizacionesGanado ( + Categoria, Especificaciones, Maximo, Minimo, Promedio, Mediano, + Cabezas, KilosTotales, KilosPorCabeza, ImporteTotal, FechaRegistro + ) + VALUES ( + @Categoria, @Especificaciones, @Maximo, @Minimo, @Promedio, @Mediano, + @Cabezas, @KilosTotales, @KilosPorCabeza, @ImporteTotal, @FechaRegistro + );"; + + await connection.ExecuteAsync(sql, cotizaciones); + } + } +} \ No newline at end of file diff --git a/src/Mercados.Infrastructure/Persistence/Repositories/FuenteDatoRepository.cs b/src/Mercados.Infrastructure/Persistence/Repositories/FuenteDatoRepository.cs new file mode 100644 index 0000000..469e899 --- /dev/null +++ b/src/Mercados.Infrastructure/Persistence/Repositories/FuenteDatoRepository.cs @@ -0,0 +1,42 @@ +using Dapper; +using Mercados.Core.Entities; +using System.Data; + +namespace Mercados.Infrastructure.Persistence.Repositories +{ + public class FuenteDatoRepository : IFuenteDatoRepository + { + private readonly IDbConnectionFactory _connectionFactory; + + public FuenteDatoRepository(IDbConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task ObtenerPorNombreAsync(string nombre) + { + using IDbConnection connection = _connectionFactory.CreateConnection(); + const string sql = "SELECT * FROM FuentesDatos WHERE Nombre = @Nombre;"; + return await connection.QuerySingleOrDefaultAsync(sql, new { Nombre = nombre }); + } + + public async Task CrearAsync(FuenteDato fuenteDato) + { + using IDbConnection connection = _connectionFactory.CreateConnection(); + const string sql = @" + INSERT INTO FuentesDatos (Nombre, UltimaEjecucionExitosa, Url) + VALUES (@Nombre, @UltimaEjecucionExitosa, @Url);"; + await connection.ExecuteAsync(sql, fuenteDato); + } + + public async Task ActualizarAsync(FuenteDato fuenteDato) + { + using IDbConnection connection = _connectionFactory.CreateConnection(); + const string sql = @" + UPDATE FuentesDatos + SET UltimaEjecucionExitosa = @UltimaEjecucionExitosa, Url = @Url + WHERE Id = @Id;"; + await connection.ExecuteAsync(sql, fuenteDato); + } + } +} \ No newline at end of file diff --git a/src/Mercados.Infrastructure/Persistence/Repositories/IBaseRepository.cs b/src/Mercados.Infrastructure/Persistence/Repositories/IBaseRepository.cs new file mode 100644 index 0000000..c1f3474 --- /dev/null +++ b/src/Mercados.Infrastructure/Persistence/Repositories/IBaseRepository.cs @@ -0,0 +1,7 @@ +namespace Mercados.Infrastructure.Persistence.Repositories +{ + // Esta interfaz no es estrictamente necesaria ahora, pero es útil para futuras abstracciones. + public interface IBaseRepository + { + } +} \ No newline at end of file diff --git a/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionGanadoRepository.cs b/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionGanadoRepository.cs new file mode 100644 index 0000000..c36a4bd --- /dev/null +++ b/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionGanadoRepository.cs @@ -0,0 +1,9 @@ +using Mercados.Core.Entities; + +namespace Mercados.Infrastructure.Persistence.Repositories +{ + public interface ICotizacionGanadoRepository : IBaseRepository + { + Task GuardarMuchosAsync(IEnumerable cotizaciones); + } +} \ No newline at end of file diff --git a/src/Mercados.Infrastructure/Persistence/Repositories/IFuenteDatoRepository.cs b/src/Mercados.Infrastructure/Persistence/Repositories/IFuenteDatoRepository.cs new file mode 100644 index 0000000..5046afa --- /dev/null +++ b/src/Mercados.Infrastructure/Persistence/Repositories/IFuenteDatoRepository.cs @@ -0,0 +1,11 @@ +using Mercados.Core.Entities; + +namespace Mercados.Infrastructure.Persistence.Repositories +{ + public interface IFuenteDatoRepository : IBaseRepository + { + Task ObtenerPorNombreAsync(string nombre); + Task ActualizarAsync(FuenteDato fuenteDato); + Task CrearAsync(FuenteDato fuenteDato); + } +} \ No newline at end of file