feat(project): Creación inicial del sistema de pronóstico del tiempo
Se establece la arquitectura completa y la funcionalidad base para la aplicación de Clima. **Backend (.NET 9):** - Se crea la estructura de la solución con proyectos para API, Worker, Core, Infrastructure y Database. - Implementado un Worker Service (`SmnSyncService`) para realizar un proceso ETL programado. - Creado un `SmnEtlFetcher` que descarga y parsea el archivo de pronóstico de 5 días del SMN. - Configurada la persistencia en SQL Server usando Dapper para repositorios y FluentMigrator para migraciones. - Desarrollada una API (`WeatherController`) que expone un endpoint para obtener el pronóstico por estación. - Implementadas políticas de resiliencia con Polly para las llamadas HTTP. **Frontend (React + Vite):** - Creado un proyecto con Vite, React 19 y TypeScript. - Desarrollada una capa de servicio (`weatherService`) y un cliente `apiClient` (axios) para la comunicación con el backend. - Implementado un hook personalizado `useWeather` para encapsular la lógica de estado (carga, datos, error). - Diseñado un `WeatherDashboard` modular inspirado en Meteored, compuesto por tres widgets reutilizables: - `CurrentWeatherWidget`: Muestra las condiciones actuales. - `HourlyForecastWidget`: Muestra el pronóstico por hora para el día completo. - `DailyForecastWidget`: Muestra un resumen para los próximos 5 días. - Configurado el proxy del servidor de desarrollo de Vite para una experiencia local fluida.
This commit is contained in:
@@ -13,7 +13,8 @@ using Microsoft.Extensions.Logging;
|
||||
namespace Clima.Infrastructure.DataFetchers
|
||||
{
|
||||
/// <summary>
|
||||
/// Realiza el proceso ETL completo para los datos de pronóstico de 5 días del SMN.
|
||||
/// Realiza el proceso de Extracción, Transformación y Carga (ETL) para los datos
|
||||
/// del pronóstico de 5 días del Servicio Meteorológico Nacional (SMN) de Argentina.
|
||||
/// </summary>
|
||||
public class SmnEtlFetcher : IDataFetcher
|
||||
{
|
||||
@@ -34,20 +35,22 @@ namespace Clima.Infrastructure.DataFetchers
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Orquesta el proceso completo de ETL: descarga, parseo y guardado en la base de datos.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string Message)> FetchDataAsync()
|
||||
{
|
||||
_logger.LogInformation("Iniciando proceso ETL para {SourceName}.", SourceName);
|
||||
try
|
||||
{
|
||||
// 1. EXTRACCIÓN (E)
|
||||
_logger.LogInformation("Descargando archivo ZIP desde la fuente de datos...");
|
||||
// 1. EXTRAER: Descarga el archivo ZIP desde la URL del SMN.
|
||||
var zipBytes = await DownloadZipFileAsync();
|
||||
if (zipBytes == null || zipBytes.Length == 0)
|
||||
{
|
||||
return (false, "La descarga del archivo ZIP falló o el archivo está vacío.");
|
||||
}
|
||||
|
||||
// 2. TRANSFORMACIÓN (T)
|
||||
// 2. TRANSFORMAR: Parsea el contenido del archivo .txt para convertirlo en objetos Pronostico.
|
||||
_logger.LogInformation("Parseando los datos del pronóstico desde el archivo de texto...");
|
||||
var pronosticosPorEstacion = ParseTxtContent(zipBytes);
|
||||
if (!pronosticosPorEstacion.Any())
|
||||
@@ -55,14 +58,11 @@ namespace Clima.Infrastructure.DataFetchers
|
||||
return (true, "Proceso completado, pero no se encontraron datos de pronóstico válidos para procesar.");
|
||||
}
|
||||
|
||||
// 3. CARGA (L)
|
||||
// 3. CARGAR: Reemplaza los datos viejos en la base de datos con los nuevos.
|
||||
_logger.LogInformation("Actualizando la base de datos con {Count} estaciones.", pronosticosPorEstacion.Count);
|
||||
foreach (var kvp in pronosticosPorEstacion)
|
||||
{
|
||||
var estacion = kvp.Key;
|
||||
var pronosticos = kvp.Value;
|
||||
await _pronosticoRepository.ReemplazarPronosticosPorEstacionAsync(estacion, pronosticos);
|
||||
_logger.LogInformation("Datos para la estación '{Estacion}' actualizados ({Count} registros).", estacion, pronosticos.Count);
|
||||
await _pronosticoRepository.ReemplazarPronosticosPorEstacionAsync(kvp.Key, kvp.Value);
|
||||
}
|
||||
|
||||
return (true, $"Proceso ETL completado exitosamente. Se procesaron {pronosticosPorEstacion.Count} estaciones.");
|
||||
@@ -74,106 +74,157 @@ namespace Clima.Infrastructure.DataFetchers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Descarga el archivo ZIP que contiene los datos del pronóstico.
|
||||
/// </summary>
|
||||
private async Task<byte[]> DownloadZipFileAsync()
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("SmnApiClient");
|
||||
return await client.GetByteArrayAsync(DataUrl);
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36"
|
||||
);
|
||||
|
||||
_logger.LogInformation("Descargando bytes desde {Url}...", DataUrl);
|
||||
var downloadedBytes = await client.GetByteArrayAsync(DataUrl);
|
||||
_logger.LogInformation("Descarga completada. Se recibieron {Length} bytes.", downloadedBytes.Length);
|
||||
|
||||
// Opcional: Guarda una copia local del archivo para facilitar la depuración manual.
|
||||
var debugFilePath = Path.Combine(Path.GetTempPath(), "smn_download_debug.zip");
|
||||
try
|
||||
{
|
||||
await File.WriteAllBytesAsync(debugFilePath, downloadedBytes);
|
||||
_logger.LogInformation("DEBUG: El archivo descargado se ha guardado para inspección en: {Path}", debugFilePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "DEBUG: Falló al intentar guardar el archivo de depuración.");
|
||||
}
|
||||
|
||||
return downloadedBytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lee el contenido de un archivo .txt dentro de un ZIP y lo transforma en un diccionario
|
||||
/// de pronósticos agrupados por nombre de estación.
|
||||
/// </summary>
|
||||
private Dictionary<string, List<Pronostico>> ParseTxtContent(byte[] zipBytes)
|
||||
{
|
||||
var pronosticosAgrupados = new Dictionary<string, List<Pronostico>>();
|
||||
|
||||
using var memoryStream = new MemoryStream(zipBytes);
|
||||
using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read);
|
||||
|
||||
// Buscamos el primer archivo .txt dentro del ZIP
|
||||
var txtEntry = archive.Entries.FirstOrDefault(e => e.Name.EndsWith(".txt", StringComparison.OrdinalIgnoreCase));
|
||||
if (txtEntry == null)
|
||||
{
|
||||
_logger.LogWarning("No se encontró un archivo .txt dentro del ZIP descargado.");
|
||||
_logger.LogWarning("No se encontró un archivo .txt en el ZIP.");
|
||||
return pronosticosAgrupados;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Procesando archivo: {FileName}", txtEntry.Name);
|
||||
using var stream = txtEntry.Open();
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
string? currentStation = null;
|
||||
List<Pronostico> currentForecasts = new List<Pronostico>();
|
||||
|
||||
string? line;
|
||||
while ((line = reader.ReadLine()) != null)
|
||||
{
|
||||
// Si la línea es la de separación, y estábamos procesando una estación, la guardamos.
|
||||
if (line.Trim().StartsWith("===="))
|
||||
{
|
||||
if (currentStation != null && currentForecasts.Any())
|
||||
{
|
||||
pronosticosAgrupados[currentStation] = new List<Pronostico>(currentForecasts);
|
||||
_logger.LogDebug("Estación '{Station}' parseada con {Count} registros.", currentStation, currentForecasts.Count);
|
||||
}
|
||||
currentStation = null;
|
||||
currentForecasts.Clear();
|
||||
continue;
|
||||
}
|
||||
string trimmedLine = line.Trim();
|
||||
if (string.IsNullOrEmpty(trimmedLine)) continue;
|
||||
|
||||
// Si no estamos en una estación, buscamos la siguiente
|
||||
if (currentStation == null)
|
||||
{
|
||||
// Una línea de nombre de estación no empieza con espacio
|
||||
if (!string.IsNullOrWhiteSpace(line) && char.IsLetter(line[0]))
|
||||
{
|
||||
currentStation = line.Trim();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// ESTRATEGIA DE PARSEO:
|
||||
// 1. Intentamos tratar la línea como un dato de pronóstico. Si tiene éxito, la procesamos y continuamos.
|
||||
// 2. Si falla (lanza una excepción), significa que no era un dato. Entonces, verificamos si es un nombre de estación.
|
||||
// 3. Si no es ninguna de las dos, es una cabecera o separador y simplemente la ignoramos.
|
||||
|
||||
// Si estamos dentro de una estación, intentamos parsear la línea de datos
|
||||
try
|
||||
{
|
||||
// Saltamos líneas vacías o de cabecera
|
||||
if (string.IsNullOrWhiteSpace(line) || !char.IsDigit(line.Trim()[0])) continue;
|
||||
|
||||
var pronostico = ParseForecastLine(currentStation, line);
|
||||
currentForecasts.Add(pronostico);
|
||||
var pronostico = ParseForecastLine(line);
|
||||
|
||||
if (currentStation != null)
|
||||
{
|
||||
pronostico.Estacion = currentStation;
|
||||
pronosticosAgrupados[currentStation].Add(pronostico);
|
||||
}
|
||||
continue; // Línea procesada con éxito, pasamos a la siguiente.
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception)
|
||||
{
|
||||
_logger.LogWarning(ex, "Se omitió una línea de pronóstico inválida para la estación '{Station}': '{Line}'", currentStation, line);
|
||||
// La excepción es esperada para cabeceras, separadores y nombres de estación.
|
||||
// Simplemente la ignoramos y dejamos que el código continúe para ver si la línea es un nombre de estación.
|
||||
}
|
||||
|
||||
// Verificamos si la línea es un nombre de estación.
|
||||
// Regla: Contiene letras y solo se compone de mayúsculas, números, '_', '(', ')' y espacios.
|
||||
if (trimmedLine.Any(char.IsLetter) && trimmedLine.All(c => char.IsUpper(c) || char.IsDigit(c) || c == '_' || c == ' ' || c == '(' || c == ')'))
|
||||
{
|
||||
currentStation = trimmedLine;
|
||||
if (!pronosticosAgrupados.ContainsKey(currentStation))
|
||||
{
|
||||
pronosticosAgrupados[currentStation] = new List<Pronostico>();
|
||||
}
|
||||
_logger.LogDebug("Cambiando a la estación: {Station}", currentStation);
|
||||
}
|
||||
}
|
||||
|
||||
return pronosticosAgrupados;
|
||||
// Filtramos estaciones que pudieron ser detectadas pero para las cuales no se encontró ningún dato válido.
|
||||
var estacionesConDatos = pronosticosAgrupados.Where(kvp => kvp.Value.Any()).ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
|
||||
|
||||
_logger.LogInformation("Parseo finalizado. Se encontraron datos para {Count} estaciones.", estacionesConDatos.Count);
|
||||
return estacionesConDatos;
|
||||
}
|
||||
|
||||
private Pronostico ParseForecastLine(string station, string line)
|
||||
/// <summary>
|
||||
/// Parsea una única línea del archivo de texto para extraer los datos de un pronóstico.
|
||||
/// Este método es estricto y lanzará una excepción si el formato no es el esperado.
|
||||
/// </summary>
|
||||
/// <param name="line">La línea de texto a parsear.</param>
|
||||
/// <returns>Una entidad <see cref="Pronostico"/>.</returns>
|
||||
/// <exception cref="FormatException">Si la línea no contiene el formato de datos esperado.</exception>
|
||||
private Pronostico ParseForecastLine(string line)
|
||||
{
|
||||
// Parseo de fecha y hora. Ej: "25/JUL/2025 00Hs."
|
||||
var fullDateString = line.Substring(2, 18).Trim();
|
||||
var datePart = fullDateString.Substring(0, 11);
|
||||
var hourPart = fullDateString.Substring(12, 2);
|
||||
// Creamos un string de fecha compatible: "25/JUL/2025 00"
|
||||
var parsableDateString = $"{datePart} {hourPart}";
|
||||
// Usamos CultureInfo para que entienda "JUL" en español
|
||||
var culture = new CultureInfo("es-AR");
|
||||
var fechaHora = DateTime.ParseExact(parsableDateString, "dd/MMM/yyyy HH", culture);
|
||||
// El método más robusto es dividir la línea por espacios, eliminando las entradas vacías.
|
||||
// Esto evita errores por pequeños desajustes en el ancho fijo de las columnas.
|
||||
var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// Parseo de valores numéricos de ancho fijo
|
||||
var temperatura = decimal.Parse(line.Substring(28, 10).Trim(), CultureInfo.InvariantCulture);
|
||||
var vientoData = line.Substring(41, 12).Split('|', StringSplitOptions.TrimEntries);
|
||||
var vientoDir = int.Parse(vientoData[0]);
|
||||
var vientoVel = int.Parse(vientoData[1]);
|
||||
var precipitacion = decimal.Parse(line.Substring(59, 10).Trim(), CultureInfo.InvariantCulture);
|
||||
// Una línea de datos válida debe tener al menos 7 partes.
|
||||
// Ejemplo: "25/JUL/2025", "00Hs.", "10.7", "51", "|", "12", "0.0"
|
||||
if (parts.Length < 7)
|
||||
{
|
||||
throw new FormatException("La línea no tiene suficientes columnas para ser un dato de pronóstico.");
|
||||
}
|
||||
|
||||
return new Pronostico
|
||||
{
|
||||
Estacion = station,
|
||||
FechaHora = fechaHora,
|
||||
TemperaturaC = temperatura,
|
||||
VientoDirGrados = vientoDir,
|
||||
VientoKmh = vientoVel,
|
||||
PrecipitacionMm = precipitacion
|
||||
};
|
||||
// --- 1. Parseo de Fecha y Hora ---
|
||||
var datePart = parts[0];
|
||||
var hourPart = parts[1].Substring(0, 2); // Tomamos solo los dígitos "00" de "00Hs."
|
||||
|
||||
// El formato del SMN usa abreviaturas de mes en mayúsculas (JUL).
|
||||
// .NET InvariantCulture, que es el estándar para parseo, espera un formato "Title Case" (Jul).
|
||||
// Normalizamos el mes para que el parseo sea exitoso y no dependa del idioma del sistema.
|
||||
string monthAbbr = datePart.Substring(3, 3);
|
||||
string monthProperCase = monthAbbr.Substring(0, 1).ToUpper() + monthAbbr.Substring(1).ToLower();
|
||||
datePart = datePart.Replace(monthAbbr, monthProperCase);
|
||||
|
||||
var parsableDateString = $"{datePart} {hourPart}";
|
||||
var culture = CultureInfo.InvariantCulture;
|
||||
var fechaHora = DateTime.ParseExact(parsableDateString, "dd/MMM/yyyy HH", culture, DateTimeStyles.AssumeUniversal).ToUniversalTime();
|
||||
|
||||
// --- 2. Parseo de los demás datos por su posición en el array resultante del Split ---
|
||||
var temperatura = decimal.Parse(parts[2], CultureInfo.InvariantCulture);
|
||||
var vientoDir = int.Parse(parts[3]);
|
||||
// El separador "|" está en parts[4], por lo que la velocidad está en parts[5]
|
||||
var vientoVel = int.Parse(parts[5]);
|
||||
var precipitacion = decimal.Parse(parts[6], CultureInfo.InvariantCulture);
|
||||
|
||||
// Devolvemos el pronóstico. La estación se asignará en el método que llama a este.
|
||||
return new Pronostico
|
||||
{
|
||||
FechaHora = fechaHora,
|
||||
TemperaturaC = temperatura,
|
||||
VientoDirGrados = vientoDir,
|
||||
VientoKmh = vientoVel,
|
||||
PrecipitacionMm = precipitacion
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user