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