feat: Implement MercadoAgroFetcher scraper and repositories
This commit is contained in:
		
							
								
								
									
										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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user