feat: Implement MercadoAgroFetcher scraper and repositories
This commit is contained in:
		
							
								
								
									
										19
									
								
								src/Mercados.Infrastructure/DataFetchers/IDataFetcher.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/Mercados.Infrastructure/DataFetchers/IDataFetcher.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | |||||||
|  | namespace Mercados.Infrastructure.DataFetchers | ||||||
|  | { | ||||||
|  |     /// <summary> | ||||||
|  |     /// Define la interfaz para un servicio que obtiene datos de una fuente externa. | ||||||
|  |     /// </summary> | ||||||
|  |     public interface IDataFetcher | ||||||
|  |     { | ||||||
|  |         /// <summary> | ||||||
|  |         /// Obtiene el nombre único de la fuente de datos. | ||||||
|  |         /// </summary> | ||||||
|  |         string SourceName { get; } | ||||||
|  |  | ||||||
|  |         /// <summary> | ||||||
|  |         /// Ejecuta el proceso de obtención, transformación y guardado de datos. | ||||||
|  |         /// </summary> | ||||||
|  |         /// <returns>Una tupla indicando si la operación fue exitosa y un mensaje de estado.</returns> | ||||||
|  |         Task<(bool Success, string Message)> FetchDataAsync(); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										160
									
								
								src/Mercados.Infrastructure/DataFetchers/MercadoAgroFetcher.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										160
									
								
								src/Mercados.Infrastructure/DataFetchers/MercadoAgroFetcher.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -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<MercadoAgroFetcher> _logger; | ||||||
|  |  | ||||||
|  |         public MercadoAgroFetcher( | ||||||
|  |             IHttpClientFactory httpClientFactory, | ||||||
|  |             ICotizacionGanadoRepository cotizacionRepository, | ||||||
|  |             IFuenteDatoRepository fuenteDatoRepository, | ||||||
|  |             ILogger<MercadoAgroFetcher> 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<string> 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<CotizacionGanado> 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<CotizacionGanado>(); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var cotizaciones = new List<CotizacionGanado>(); | ||||||
|  |             // 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); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -5,6 +5,7 @@ | |||||||
|   </ItemGroup> |   </ItemGroup> | ||||||
|  |  | ||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|  |     <PackageReference Include="AngleSharp" Version="1.3.0" /> | ||||||
|     <PackageReference Include="Dapper" Version="2.1.66" /> |     <PackageReference Include="Dapper" Version="2.1.66" /> | ||||||
|     <PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" /> |     <PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" /> | ||||||
|     <PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" /> |     <PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" /> | ||||||
|   | |||||||
| @@ -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<CotizacionGanado> 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); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -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<FuenteDato?> ObtenerPorNombreAsync(string nombre) | ||||||
|  |         { | ||||||
|  |             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||||
|  |             const string sql = "SELECT * FROM FuentesDatos WHERE Nombre = @Nombre;"; | ||||||
|  |             return await connection.QuerySingleOrDefaultAsync<FuenteDato>(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); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -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 | ||||||
|  |     { | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,9 @@ | |||||||
|  | using Mercados.Core.Entities; | ||||||
|  |  | ||||||
|  | namespace Mercados.Infrastructure.Persistence.Repositories | ||||||
|  | { | ||||||
|  |     public interface ICotizacionGanadoRepository : IBaseRepository | ||||||
|  |     { | ||||||
|  |         Task GuardarMuchosAsync(IEnumerable<CotizacionGanado> cotizaciones); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,11 @@ | |||||||
|  | using Mercados.Core.Entities; | ||||||
|  |  | ||||||
|  | namespace Mercados.Infrastructure.Persistence.Repositories | ||||||
|  | { | ||||||
|  |     public interface IFuenteDatoRepository : IBaseRepository | ||||||
|  |     { | ||||||
|  |         Task<FuenteDato?> ObtenerPorNombreAsync(string nombre); | ||||||
|  |         Task ActualizarAsync(FuenteDato fuenteDato); | ||||||
|  |         Task CrearAsync(FuenteDato fuenteDato); | ||||||
|  |     } | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user