UNPKG

aemet-api

Version:

Cliente TypeScript para la API de AEMET (Agencia Estatal de Meteorología)

319 lines (318 loc) 15.8 kB
"use strict"; /** * Utilidades para la librería de AEMET */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.fetchAemetData = fetchAemetData; exports.getSkyStateDescription = getSkyStateDescription; exports.formatDate = formatDate; exports.getDayForecast = getDayForecast; exports.fetchAemetBinaryFile = fetchAemetBinaryFile; const axios_1 = __importDefault(require("axios")); const constants_1 = require("./constants"); /** * Realizar una petición GET a la API de AEMET y descargar los datos * @param url - URL del endpoint de la API * @param apiKey - Clave de API AEMET * @param timeout - Timeout para la petición en milisegundos * @returns - Datos procesados de la respuesta con número de intentos realizados */ async function fetchAemetData(url, apiKey, timeout = 10000) { const MAX_RETRIES = 12; let intentos = 1; let lastError = null; while (intentos <= MAX_RETRIES) { try { // Log simplificado de la petición inicial if (intentos > 1) { console.log(`Petición a AEMET (Intento ${intentos}/${MAX_RETRIES})`); } // Realizamos la petición inicial para obtener los URLs de datos y metadatos const apiResponse = await axios_1.default.get(url, { headers: { 'api_key': apiKey, 'accept': 'application/json' }, timeout, responseType: 'text', // Para manejar tanto JSON como texto plano }); // Manejamos el caso especial de AEMET que puede devolver 200 con cuerpo vacío if (apiResponse.status === 200 && (!apiResponse.data || (typeof apiResponse.data === 'string' && apiResponse.data.trim() === ""))) { throw new Error('La API devolvió una respuesta vacía. Verifique que la URL y la API key sean correctas.'); } // Si la respuesta es texto plano pero parece contener JSON, intentamos parsearlo let responseData = apiResponse.data; if (typeof apiResponse.data === 'string' && apiResponse.headers['content-type']?.includes('text/plain') && apiResponse.data.trim().startsWith('{')) { try { responseData = JSON.parse(apiResponse.data); } catch (parseError) { console.error('Error: No se pudo parsear la respuesta como JSON'); } } responseData = JSON.parse(responseData); // Valores climatológicos: AEMET devuelve estado = 0 como indicador de éxito // El formato es: { "descripcion": "exito", "estado": 200, "datos": URL, "metadatos": URL } if (responseData) { const dataUrl = responseData.datos; if (!dataUrl) { throw new Error('URL de datos no disponible en la respuesta de la API'); } // Si datos es una URL, la descargamos if (typeof dataUrl === 'string' && (dataUrl.startsWith('http://') || dataUrl.startsWith('https://'))) { // Log simplificado para descarga de datos if (intentos === 1) { console.log(`Obteniendo datos AEMET...`); } try { const response = await axios_1.default.get(dataUrl, { timeout, headers: { 'accept': 'application/json' }, responseType: 'text' // Para manejar tanto JSON como texto plano }); // Intentar parsear si es texto plano pero parece ser JSON let data = response.data; if (typeof response.data === 'string' && response.headers['content-type']?.includes('text/plain') && (response.data.trim().startsWith('[') || response.data.trim().startsWith('{'))) { try { data = JSON.parse(response.data); } catch (parseError) { console.error('Error: No se pudo parsear los datos como JSON'); } } // Añadir el número de intentos realizados a los datos if (typeof data === 'object' && data !== null) { data.intentos = intentos; } else if (Array.isArray(data)) { // Si es un array, añadimos los intentos a cada elemento si son objetos data = data.map(item => { if (typeof item === 'object' && item !== null) { return { ...item, intentos }; } return item; }); } else { // Si no es un objeto ni un array, envolvemos el resultado data = { data, intentos }; } return data; } catch (dataError) { console.error('Error: No se pudieron obtener los datos de AEMET'); if (axios_1.default.isAxiosError(dataError) && dataError.response) { console.error(`Error ${dataError.response.status}`); } throw new Error(`Error al acceder a la URL de datos: ${dataError instanceof Error ? dataError.message : 'Error desconocido'}`); } } else { // Si datos no es una URL sino directamente la información responseData.intentos = intentos; return responseData; // Devolver el objeto completo con { "descripcion": "Éxito", "estado": 0, "datos": ..., "metadatos": ..., "intentos": ... } } } else { throw new Error(`Error en la respuesta de la API: ${responseData?.descripcion || 'Sin descripción'} (Estado: ${responseData?.estado || apiResponse.status})`); } } catch (error) { lastError = error instanceof Error ? error : new Error('Error desconocido'); // Verificar si el error es "socket hang up" para reintentar const errorMessage = error instanceof Error ? error.message : 'Error desconocido'; const isSocketHangUp = errorMessage.includes('socket hang up'); if (isSocketHangUp && intentos < MAX_RETRIES) { console.log(`Error de conexión. Reintentando (${intentos}/${MAX_RETRIES})...`); // Esperar un tiempo antes de reintentar (tiempo exponencial) const retryDelay = Math.min(1000 * Math.pow(2, intentos - 1), 10000); await new Promise(resolve => setTimeout(resolve, retryDelay)); intentos++; continue; } if (axios_1.default.isAxiosError(error)) { console.error('Error en la petición:', error.message); if (error.response) { // Verificar si la respuesta es un HTML (error de Tomcat) const contentType = error.response.headers['content-type']; if (contentType && contentType.includes('text/html')) { // En caso de respuesta HTML, proporcionar un mensaje más claro if (error.response.status === 404) { throw new Error(`Recurso no encontrado (404). Verifique que los parámetros sean correctos y que no esté solicitando datos futuros.`); } else { throw new Error(`Error del servidor (${error.response.status}). Intente más tarde.`); } } else { throw new Error(`Error en la petición a la API: ${error.response.status}`); } } else if (error.request) { throw new Error(`No se recibió respuesta de la API: ${error.message}`); } else { throw new Error(`Error al configurar la petición a la API: ${error.message}`); } } throw lastError; } } // Si llegamos aquí, es porque se agotaron los reintentos throw new Error(`Se agotaron los reintentos (${MAX_RETRIES}). Último error: ${lastError?.message || 'Desconocido'}`); } /** * Obtener descripción del estado del cielo a partir de su código * @param code - Código del estado del cielo * @returns - Descripción del estado del cielo */ function getSkyStateDescription(code) { return constants_1.SKY_STATES[code] || 'Desconocido'; } /** * Formatea una fecha como YYYY-MM-DD * @param date - Fecha a formatear * @returns - Fecha formateada */ function formatDate(date) { return date.toISOString().split('T')[0]; } /** * Extrae la predicción para un día específico * @param forecast - Datos completos de la predicción * @param date - Fecha para la que se quiere la predicción (o 0 para hoy, 1 para mañana, 2 para pasado mañana) * @returns - Predicción para el día especificado */ function getDayForecast(forecast, date) { if (typeof date === 'number') { const today = new Date(); today.setDate(today.getDate() + date); date = today; } if (!(date instanceof Date) || isNaN(date.getTime())) { throw new Error('Fecha inválida'); } const dateStr = formatDate(date); if (!forecast || !Array.isArray(forecast)) { throw new Error('El forecast no es un array válido'); } return forecast.find((day) => day && day.fecha === dateStr); } /** * Descargar un archivo binario desde la API de AEMET * Especialmente útil para archivos como .tar que no son JSON * @param url - URL del endpoint de la API * @param apiKey - Clave de API AEMET * @param timeout - Timeout para la petición en milisegundos * @returns - Buffer con los datos binarios del archivo y número de intentos realizados */ async function fetchAemetBinaryFile(url, apiKey, timeout = 10000) { const MAX_RETRIES = 12; let intentos = 1; let lastError = null; while (intentos <= MAX_RETRIES) { try { if (intentos > 1) { console.log(`Descarga de archivo binario (Intento ${intentos}/${MAX_RETRIES})`); } else { console.log(`Descargando archivo de alertas...`); } // Realizamos la petición inicial para obtener la URL de datos const apiResponse = await axios_1.default.get(url, { headers: { 'api_key': apiKey, 'accept': 'application/json' }, timeout: 15000, // Aumentamos el timeout para la petición inicial responseType: 'json' }); // Verificar la respuesta if (!apiResponse.data || !apiResponse.data.datos) { throw new Error('URL de datos no disponible en la respuesta de la API'); } const dataUrl = apiResponse.data.datos; // Descargar el archivo binario try { const response = await axios_1.default.get(dataUrl, { responseType: 'arraybuffer', timeout: timeout, // No establecemos headers de 'accept' porque queremos los datos binarios tal cual }); // Verificar que los datos recibidos no estén vacíos if (!response.data || response.data.byteLength === 0) { throw new Error('Se recibió un archivo vacío'); } // Log simplificado console.log(`Archivo descargado (${response.data.byteLength} bytes)`); // Devolver los datos binarios como Buffer return { data: Buffer.from(response.data), intentos }; } catch (downloadError) { console.error('Error al descargar el archivo binario'); if (intentos < MAX_RETRIES) { const retryDelay = Math.min(1000 * Math.pow(2, intentos - 1), 10000); console.log(`Reintentando descarga en ${Math.round(retryDelay / 1000)}s...`); await new Promise(resolve => setTimeout(resolve, retryDelay)); intentos++; continue; } throw downloadError; } } catch (error) { lastError = error instanceof Error ? error : new Error('Error desconocido'); // Verificar si el error es "socket hang up" para reintentar const errorMessage = error instanceof Error ? error.message : 'Error desconocido'; const isSocketHangUp = errorMessage.includes('socket hang up') || errorMessage.includes('timeout'); if (isSocketHangUp && intentos < MAX_RETRIES) { console.log(`Error de conexión. Reintentando (${intentos}/${MAX_RETRIES})...`); // Esperar un tiempo antes de reintentar (tiempo exponencial) const retryDelay = Math.min(1000 * Math.pow(2, intentos - 1), 10000); await new Promise(resolve => setTimeout(resolve, retryDelay)); intentos++; continue; } if (axios_1.default.isAxiosError(error)) { console.error('Error en la petición:', error.message); if (error.response) { // Verificar si la respuesta es un HTML (error de Tomcat) const contentType = error.response.headers['content-type']; if (contentType && contentType.includes('text/html')) { // En caso de respuesta HTML, proporcionar un mensaje más claro if (error.response.status === 404) { throw new Error(`Recurso no encontrado (404). Verifique que los parámetros sean correctos.`); } else { throw new Error(`Error del servidor (${error.response.status}). Intente más tarde.`); } } else { throw new Error(`Error en la petición a la API: ${error.response.status}`); } } else if (error.request) { throw new Error(`No se recibió respuesta de la API: ${error.message}`); } else { throw new Error(`Error al configurar la petición a la API: ${error.message}`); } } throw lastError; } } // Si llegamos aquí, es porque se agotaron los reintentos throw new Error(`Se agotaron los reintentos (${MAX_RETRIES}). Último error: ${lastError?.message || 'Desconocido'}`); }