GLP Cerca (2): el dato manda — MINETUR, ingesta e histórico barato

Publicado el 02/07/2026

Toda app de precios es, en el fondo, una app de datos ajenos. La calidad del producto está topada por la calidad con la que ingieres, normalizas y sirves esos datos. Así que empecé por aquí, no por la interfaz.

La fuente: datos abiertos de MINETUR

El Ministerio publica los precios de carburantes como datos abiertos a través de un servicio HTTP. Para el GLP me interesa un endpoint en concreto:

EstacionesTerrestres/FiltroProducto/17     (17 = GLP)

La gracia de FiltroProducto es que el servidor ya me devuelve solo las estaciones que despachan ese producto y su precio, en lugar de tener que descargar el listado completo del país y filtrar yo. Menos datos por la red, menos lógica en mi lado. El producto_id es configurable (INGESTA_PRODUCTO_ID), así que el mismo binario serviría para diésel o gasolina cambiando una variable de entorno.

El feed es oficial, pero no es cómodo

Aquí llegan las primeras lecciones. Un feed oficial no significa un feed limpio. Estas son las rarezas que tuve que domesticar, todas concentradas en el módulo fuente.rs:

1. Coma decimal

Los precios y coordenadas vienen con coma decimal al estilo español: "1,065", "38,999722". f64::from_str no entiende eso, así que hay una función diminuta pero crítica:

/// Convierte un número con coma decimal ("1,065") a `f64`. Vacío ⇒ `None`.
fn num(s: &str) -> Option<f64> {
    let s = s.trim();
    if s.is_empty() {
        return None;
    }
    s.replace(',', ".").parse().ok()
}

Devuelve Option a propósito: un campo vacío no es un error, es simplemente un dato que no está. Esa decisión se propaga hacia arriba.

2. Claves JSON con acentos y espacios

Las claves del JSON son literalmente "Rótulo", "Longitud (WGS84)", "C.P.", "Tipo Venta". Con serde esto se resuelve con rename, pero merece verse porque deja claro que estás consumiendo un formato pensado para personas, no para máquinas:

#[derive(Debug, Deserialize)]
struct EstacionCruda {
    #[serde(rename = "IDEESS")]            ideess: String,
    #[serde(rename = "Rótulo")]           rotulo: String,
    #[serde(rename = "Longitud (WGS84)", default)] longitud: String,
    #[serde(rename = "PrecioProducto", default)]   precio_producto: String,
    // …
}

3. Fecha en horario peninsular

El feed trae una fecha "27/06/2026 11:57:03" sin zona horaria. La interpreto como hora peninsular con un offset fijo de +02:00:

let offset = FixedOffset::east_opt(2 * 3600).unwrap();

Decisión consciente (y documentada en el código): uso +02:00 (horario de verano) todo el año. En invierno me desvío una hora. ¿Es incorrecto? Sí. ¿Importa para lo que hago con esa fecha —detectar tendencias y marcar el servicio como "obsoleto" si deja de refrescar—? No. Asumir un error irrelevante a sabiendas es mejor ingeniería que añadir una dependencia de zonas horarias para una precisión que nadie va a usar. Lo importante es dejarlo escrito para el yo del futuro.

4. El TLS que no quería negociar

Esta me costó una tarde. El cliente HTTP por defecto que iba a usar (reqwest con rustls) fallaba el handshake TLS contra el servidor de MINETUR. curl conectaba sin problema. Tras descartar lo evidente, la causa era el servidor rechazando el handshake concreto de rustls.

La solución fue cambiar a native-tls con OpenSSL vendorizado, y lo dejé comentado en el Cargo.toml para que nadie (yo incluido) "optimice" esto volviendo a rustls:

# Se usa native-tls (OpenSSL) en lugar de rustls porque el servidor de MINETUR
# rechaza el handshake TLS de rustls (curl/OpenSSL sí conecta). La variante
# `-vendored` compila OpenSSL en el binario: no requiere libssl en runtime.
reqwest = { version = "0.12", default-features = false, features = [
    "json", "native-tls-vendored", "gzip",
] }

-vendored además me ahorra problemas de despliegue: OpenSSL va compilado dentro del binario, así que el contenedor de producción no necesita libssl instalado.

5. Descartar lo inservible, contarlo siempre

Una estación sin precio o sin coordenadas no me sirve: no puedo ordenarla ni ubicarla. La descarto. Pero no en silencio: cuento cuántas descarto y lo registro.

let (Some(precio), Some(latitud), Some(longitud), Ok(ideess)) = (
    num(&c.precio_producto),
    num(&c.latitud),
    num(&c.longitud),
    c.ideess.trim().parse::<i32>(),
) else {
    continue;
};

Ese let-else con una tupla de Option/Result es uno de mis patrones favoritos de Rust: "necesito que estas cuatro cosas existan a la vez; si no, salto". El contador de descartadas viaja hasta los logs y la bitácora, porque un salto en las descartadas suele significar que la fuente cambió de formato, y quiero enterarme.

Ingesta: el "histórico barato"

Con el feed parseado, toca persistir. Y aquí está la decisión de modelado de datos de la que estoy más contento.

Quiero dos cosas a la vez:

  1. El estado actual de cada estación (para responder "¿cuánto cuesta ahora?").
  2. Un histórico para poder mostrar tendencias ("¿está subiendo o bajando?").

La tentación ingenua es guardar un snapshot completo en cada ingesta. Si ingiero 4 veces al día y hay 5.000 estaciones con GLP, eso son 20.000 filas diarias, 7,3 millones al año, la inmensa mayoría idénticas a la anterior. Absurdo.

La estrategia que adopté:

  • Tabla estacion: un upsert por estación (clave ideess). Siempre refleja el último precio. Es el snapshot.
  • Tabla precio_historico: se inserta una fila solo cuando el precio cambia respecto al último registrado.
match existentes.get(&est.ideess) {
    None => {
        // Estación nueva: insertar + primer punto de histórico.
        let id = insertar_estacion(db, &est, fecha).await?;
        insertar_historico(db, id, est.precio, fecha).await?;
        existentes.insert(est.ideess, (id, est.precio));
        resumen.nuevas += 1;
    }
    Some(&(id, precio_anterior)) => {
        actualizar_estacion(db, id, &est, fecha).await?;
        resumen.actualizadas += 1;
        // El histórico SOLO crece si el precio se movió.
        if !mismo_precio(precio_anterior, est.precio) {
            insertar_historico(db, id, est.precio, fecha).await?;
            resumen.precios_cambiados += 1;
        }
    }
}

La comparación de precios no usa == (los f64 y la igualdad exacta no se llevan bien), sino una tolerancia acorde a los 3 decimales del feed:

/// Dos precios se consideran iguales si difieren menos de medio milésimo.
fn mismo_precio(a: f64, b: f64) -> bool {
    (a - b).abs() < 0.0005
}

Por qué me gusta: el histórico crece proporcional a los cambios reales de precio, no al número de ingestas. Una estación que no toca su precio en un mes ocupa una fila, no 120. Y para dibujar una gráfica de tendencia es exactamente lo que quieres: los puntos de inflexión, sin ruido. Es un histórico que cuenta una historia, no un log de polling.

Dos detalles que parecen menores y no lo son

IDEESS duplicados en el mismo feed. MINETUR a veces trae el mismo IDEESS dos veces en la misma respuesta. Si trato ambas apariciones como "nueva", la segunda viola la constraint unique(ideess) y revienta la ingesta. La solución es que el mapa de existentes que precargo sea mutable y se actualice al vuelo: en cuanto inserto una estación, la registro, de modo que su segunda aparición en el mismo feed se procese como actualización.

// Precarga ideess → (id, precio_actual) para no hacer N consultas. Es mutable:
// se actualiza al insertar para que un mismo IDEESS repetido en el feed se trate
// como actualización en su segunda aparición.
let mut existentes: HashMap<i32, (i32, f64)> = /* … */;

(De paso, esa precarga evita el clásico problema N+1: una sola consulta para saber qué existe, en vez de una por estación.)

Catálogo de provincias autorreparable. Al final de cada ingesta, una sentencia siembra cualquier provincia vista que aún no esté en el catálogo, con un nombre legible provisional, sin pisar los nombres "bonitos" ya curados (ON CONFLICT DO NOTHING). Así el sistema nunca se queda sin poder mostrar una provincia solo porque sea nueva.

La ingesta deja rastro

Cada ejecución abre una fila en ingesta_log con ok = false, y solo la cierra en true si termina bien. Si algo falla, queda registrado el error. Esa bitácora es la que alimenta el endpoint /health, que marca el servicio como stale si llevamos demasiado tiempo sin una ingesta correcta.

let log = ingesta_log::ActiveModel {
    iniciada_at: Set(Utc::now().into()),
    ..Default::default()
}.insert(db).await?;
// … integrar … luego cerrar_log(db, log_id, &resumen, ok, err)

Principio: un proceso de datos que se ejecuta solo, sin nadie mirando, tiene que poder contar qué le pasó. Si la ingesta falla a las 3 de la mañana, quiero que /health me lo diga, no enterarme porque un usuario ve precios de hace tres días.

Lo que me llevo de esta capa

  • Normaliza en la frontera. Todo lo raro del feed (comas, acentos, fechas, vacíos) se resuelve en fuente.rs. De ahí para adentro circulan datos limpios y tipados. El resto del sistema no sabe que MINETUR usa coma decimal.
  • Modela para tu pregunta, no para tu fuente. El "histórico barato" existe porque la pregunta del usuario es "¿sube o baja?", no "¿qué devolvió el feed a las 11:57?".
  • Documenta los porqués raros en el código. El comentario del TLS o el del offset horario valen oro dentro de seis meses.

En el siguiente artículo subo un nivel: el servidor que sirve estos datos. Por qué Rust, cómo está montada la API y cómo resuelvo la búsqueda por cercanía sin recurrir a PostGIS.