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:
- El estado actual de cada estación (para responder "¿cuánto cuesta ahora?").
- 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 (claveideess). 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
/healthme 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.