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