| 
									
										
										
										
											2025-07-01 11:39:04 -03:00
										 |  |  | 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)) | 
					
						
							|  |  |  |                 { | 
					
						
							| 
									
										
										
										
											2025-07-03 15:55:48 -03:00
										 |  |  |                     // Esto sigue siendo un fallo, no se pudo obtener la página | 
					
						
							| 
									
										
										
										
											2025-07-01 11:39:04 -03:00
										 |  |  |                     return (false, "No se pudo obtener el contenido HTML."); | 
					
						
							|  |  |  |                 } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |                 var cotizaciones = ParseHtmlToEntities(htmlContent); | 
					
						
							| 
									
										
										
										
											2025-07-03 15:55:48 -03:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-07-01 11:39:04 -03:00
										 |  |  |                 if (!cotizaciones.Any()) | 
					
						
							|  |  |  |                 { | 
					
						
							| 
									
										
										
										
											2025-07-03 15:55:48 -03:00
										 |  |  |                     // La conexión fue exitosa, pero no se encontraron datos válidos. | 
					
						
							|  |  |  |                     // Esto NO es un error crítico, es un estado informativo. | 
					
						
							|  |  |  |                     _logger.LogInformation("La conexión con {SourceName} fue exitosa, pero no se encontraron datos de cotizaciones para procesar.", SourceName); | 
					
						
							|  |  |  |                     return (true, "Conexión exitosa, pero no se encontraron nuevos datos."); | 
					
						
							| 
									
										
										
										
											2025-07-01 11:39:04 -03:00
										 |  |  |                 } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |                 await _cotizacionRepository.GuardarMuchosAsync(cotizaciones); | 
					
						
							|  |  |  |                 await UpdateSourceInfoAsync(); | 
					
						
							| 
									
										
										
										
											2025-07-03 12:11:08 -03:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-07-01 11:39:04 -03:00
										 |  |  |                 _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) | 
					
						
							|  |  |  |             { | 
					
						
							| 
									
										
										
										
											2025-07-03 15:55:48 -03:00
										 |  |  |                 // Un catch aquí sí es un error real (ej. 404, timeout, etc.) | 
					
						
							| 
									
										
										
										
											2025-07-01 11:39:04 -03:00
										 |  |  |                 _logger.LogError(ex, "Ocurrió un error durante el fetch para {SourceName}.", SourceName); | 
					
						
							|  |  |  |                 return (false, $"Error: {ex.Message}"); | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         private async Task<string> GetHtmlContentAsync() | 
					
						
							|  |  |  |         { | 
					
						
							| 
									
										
										
										
											2025-07-03 12:11:08 -03:00
										 |  |  |             // Pedimos el cliente HTTP con el nombre específico que tiene la política de Polly | 
					
						
							|  |  |  |             var client = _httpClientFactory.CreateClient("MercadoAgroFetcher"); | 
					
						
							| 
									
										
										
										
											2025-07-01 11:39:04 -03:00
										 |  |  |             // 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); | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  |         } | 
					
						
							| 
									
										
										
										
											2025-07-03 12:11:08 -03:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-07-01 11:39:04 -03:00
										 |  |  |         // --- 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); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | } |