Dónde está tu autobús en Zaragoza, cuánto falta, y qué te están ocultando las fuentes.
44 líneas · 934 paradas · sin base de datos · sin cuentas · sin cookies
🚌 Verlo funcionando → zetabus.antonioblanquez.es
No hay nada que instalar y no es una demo con datos de mentira: son los autobuses que hay ahora mismo en la calle. Busca tu parada por el número del poste, o mira una línea con su recorrido de hoy.
🕐 La hora importa, y no es un fallo. De madrugada casi no hay servicio, así que una parada puede salir sin llegadas: eso es la respuesta correcta —ZetaBus no inventa un autobús que no viene—. Y el aviso de desvío solo aparece en las líneas que hoy tengan obras: el día que Avanza restaure la ruta, se apaga solo.
Para levantarlo en tu máquina, dos comandos → Poner en marcha.
ZetaBus dice cuánto falta para que llegue tu autobús en Zaragoza, dónde está exactamente ahora mismo en el mapa, y qué autobús es: si es articulado de 18 metros o sencillo de 12, si es eléctrico, híbrido o diésel.
Y hace una cosa más, que es la razón de que exista: enseña lo que las fuentes oficiales no cuentan. Cuando hay obras, el GTFS oficial de la ciudad sigue diciendo que el autobús pasa por una avenida cortada. ZetaBus compara ese recorrido oficial con el que el operador publica para hoy, y deduce el desvío — con las paradas por las que hoy no se pasa, tachadas y con su fuente al lado. No lo transcribe nadie a mano: se deriva, y por eso se apaga solo el día que restauren la ruta.
¿Prefieres verlo antes de leer nada más? Está en vivo en zetabus.antonioblanquez.es.
La línea 35, hoy. Las cinco paradas tachadas no las publica ninguna fuente como suprimidas: salen de comparar el recorrido oficial con el que Avanza publica para hoy. Y ZetaBus dice de dónde lo saca — y hasta dónde no llega.
En Zaragoza no hay GTFS-RT. No es que no lo hayamos encontrado: no existe, verificado contra el Punto de Acceso Nacional, Transitland y Mobility Database. Ésa es la razón —y la única— por la que en este proyecto hay scraping.
Pero el problema de fondo no es que falte una API. Es que las fuentes que sí existen se contradicen, y nadie lo dice:
- El GTFS oficial da la topología, y miente cuando hay obras. Cero
exception_type=2encalendar_dates. Sus rutas siguen bajando por calles cortadas. Sushapes.txtes preciso y falso. - El recorrido REAL de hoy está en la web del operador, en un endpoint de su WordPress: la secuencia ordenada de paradas del recorrido vigente, por sentido, con el desvío ya aplicado.
- Y hay algo que no refleja ninguna fuente: cuando el autobús pasa pero no para, la ruta operativa no cambia. El GTFS lista esa parada, la web la lista, y el sistema de tiempo real sigue anunciando autobuses en un poste suprimido. No hay base de datos en el mundo que diga la verdad sobre esa parada. Solo un cartel en la marquesina.
Ante eso ZetaBus no adjudica. Enseña los dos hechos y quién los dice: «Avanza declaró esta parada suprimida el 10/01. Su sistema sigue anunciando autobuses aquí. No podemos saber cuál es cierto — confírmalo en la marquesina.»
Esa es la diferencia entre esto y un scraper con un mapa encima: primero se auditó la fuente,
después se escribió el código. Los informes están en docs/auditoria/ — trece,
incluidos los tres en que hubo que retractarse de un informe propio.
Cuando el operador no responde, ZetaBus lo dice — en vez de inventar un autobús o dejar la pantalla en blanco.
(Estado simulado con
?fingir=caido; la banda roja es el propio aviso de demo de la app.)
Las 44 líneas, agrupadas por tipo: diurnas, circulares, lanzaderas y búhos. |
En el móvil, que es donde se usa. |
El buscador encuentra por número de poste —el que está impreso en la marquesina—, por nombre de parada o por línea. Y encuentra igual escribiendo «avda», «Av.» o «Avenida», con acentos o sin ellos, y hasta «Carlos Quinto» para «Carlos V».
Once autobuses en vivo, con su coordenada GPS real. De cada uno: modelo, longitud —articulado de 18 m o sencillo de 12— y combustible. La lista y el mapa están sincronizados: pulsa un autobús y se aísla en los dos.
⭐ Lo primero que se ve es el dato. No un botón de guardar, no el mapa: los minutos. |
La línea: recorrido en orden, transbordos en cada parada, y las primeras y últimas salidas con la frecuencia media. |
Con la frase que resume el proyecto entero: «No lo decimos nosotros: lo dice su ruta.» Y debajo, sus propios límites: «Puede haber otras paradas suprimidas que no detectamos.»
Lo que ves al abrir una parada
- Los minutos que faltan, arriba del todo. El mapa va debajo, siempre.
- Cada autobús con su posición GPS real, no interpolada.
- Qué vehículo es: modelo, longitud (articulado / sencillo) y combustible.
- Filtro por línea y selección cruzada: pulsa en el mapa y su fila viene a ti.
- La edad del dato, a la vista: «Datos de Avanza hace 6 s». Nunca se disimula.
Lo que ves al abrir una línea
- El recorrido real de hoy, en orden, con las obras ya aplicadas.
- Los transbordos en cada parada: qué otras líneas pasan por ahí.
- Primeras y últimas salidas por sentido, y la frecuencia media.
- El desvío, si lo hay, con las paradas suprimidas tachadas y su procedencia.
Lo que ZetaBus no hace
- No adivina. Si un dato no está, dice que no está — nunca un valor por defecto.
- No adjudica. Cuando dos fuentes se contradicen, enseña las dos y quién las dice.
- No pide nada. Sin cuentas, sin registro, sin cookies propias, sin analítica.
Accesibilidad, y medida
- Ningún estado se comunica solo con el color: siempre hay forma o palabra. (Y hay motivo: 22 de las 44 líneas caen en la franja rojo/ámbar/verde, y la 31 es literalmente el mismo rojo que «retraso».)
- El número de cada línea se lee sobre cualquier color de fondo, garantizado por contraste calculado, no elegido a ojo.
- Zonas táctiles, contraste y navegación por teclado verificados sobre la pantalla pintada, no sobre el CSS declarado.
| Fuente | Qué aporta | Qué no |
|---|---|---|
| GTFS del Punto de Acceso Nacional | Topología: paradas, líneas, trazados, calendario | Miente con las obras. No tiene tiempo real |
| Web del operador (recorrido) | El recorrido vigente hoy, por sentido, con el desvío aplicado | No dice que sea un desvío: hay que derivarlo |
| Sistema de tiempo real del operador | Posición GPS y minutos de llegada | Anuncia autobuses en paradas suprimidas |
| Pliego municipal de contratación | El registro oficial de la flota: 350 vehículos | Se aprobó en 2025: no trae los posteriores |
| busesmadrid.es | Los 43 vehículos que circulan y no están en el pliego | No es oficial → salen marcados con asterisco |
| OpenStreetMap | La cartografía | — |
Las dos fuentes principales dan 393; con algunas fuentes menores se llega a los 403 vehículos que ZetaBus reconoce, y cada ficha dice de qué fuente sale cada uno de sus campos — no el vehículo entero: el campo. Un coche del pliego con una longitud observada a mano no se blanquea por el resto.
Y una que salió por sorpresa: gracias al pliego municipal descubrimos que el fichero de flota heredado mentía en el 20 % de las longitudes —decía «12 metros» donde había un articulado de 18—. Un Volvo 7905 existe en las dos longitudes, con el mismo nombre de modelo.
⚖️ Cada dato lleva de dónde sale hasta la pantalla: /sobre-los-datos
lo explica dentro de la propia aplicación.
ZetaBus consulta endpoints no documentados del operador. No lo escondemos, y no nos parece que haya que esconderlo: lo justificamos.
- No hay API pública, y se verificó que no existe contra tres registros independientes.
- Se consume, no se redistribuye. No hay ni un byte de datos raspados en este repositorio. Consumir un endpoint es ser un cliente de su web; republicarlo sería otra cosa muy distinta.
- Se pide con techo y con cortesía: caché compartida en servidor (10 personas en la misma parada = 1 petición, no 10), límite duro de peticiones por segundo, tiempo de espera, cortacircuitos, y cero peticiones cuando nadie está mirando.
- Vamos identificados. Cada petición lleva
ZetaBus/1.0 (+https://github.com/ablanquez/zetabus): nombre, versión y la URL de este repositorio, donde está explicado todo y quién lo firma. Si molestamos, queremos que puedan pedirnos que paremos antes de tener que bloquearnos. - Y el
robots.txtno indexa las paradas — ni por cortesía ni por ahorro: porque los minutos que faltan caducan en 15 segundos, y una parada indexada enseñaría un «llega en 3 min» de hace tres semanas.
| Capa | Tecnología |
|---|---|
| Framework | Next.js 16 (App Router) · React 19 |
| Lenguaje | TypeScript en modo estricto |
| Mapa | Leaflet + OpenStreetMap |
| Estilos | Tailwind CSS 4, sobre un sistema de tokens propio |
| Datos | GTFS horneado en el build · sin base de datos |
| Pruebas | Vitest (motor) + Playwright (pantalla) |
No es una carencia. Todo lo que ZetaBus enseña se deriva de sus fuentes, y lo derivable no se guarda. El día que se quiera medir —frecuencia real contra frecuencia contratada— hará falta histórico, y ese día se decide entonces, con los ojos abiertos.
src/
core/ el vocabulario del dominio. No sabe que esto son autobuses.
sources/ todo lo que habla con el mundo. Un ÚNICO punto de salida a la red.
engine/ la lógica: llegadas, desvíos, horarios, correspondencias, búsqueda.
cache/ caché de dos pisos (memoria + disco), vuelo único y techo de peticiones.
components/ la interfaz.
app/ las rutas.
scripts/ los compiladores de datos: GTFS → artefacto, flota, correspondencias.
tests/ el motor, con dobles. No toca la red.
e2e/ el instrumento: mide LA PANTALLA, no el código.
docs/ la auditoría de fuentes y las decisiones de diseño.
- Node.js 20.9 o superior.
- Una ApiKey del Punto de Acceso Nacional (registro gratuito, se emite al instante) para descargar el GTFS.
git clone https://github.com/ablanquez/zetabus
cd zetabus
npm install
cp .env.example .env.local # y pon dentro tu NAP_API_KEY
npm run build # descarga el GTFS oficial (~6,6 MB), hornea src/generated y compila (~6 min)
npm run dev # http://localhost:3000npm run build, no por npm run dev a secas. dev solo
levanta el servidor; la aplicación importa los datos ya horneados en src/generated/ —que no
se versiona— y ese artefacto lo genera el build (data:build, dentro de él), no la descarga
del GTFS. Sin ese paso, dev —y las pruebas— revientan al resolver @/generated. Después del
primer build ya puedes iterar con npm run dev a secas.
El GTFS no viene en el repositorio. El porqué —que no es legal, es de frescura— está en
data/gtfs/README.md. Y el build falla ruidosamente si no está: no
arranca con datos vacíos, porque un mapa sin paradas que no se queja es peor que un error.
Para cada poste, qué líneas pasan hoy — separando las de siempre de las que hoy pasan por un
desvío. Se genera solo con npm run build, y si el operador está caído durante el build,
el build no se muere: la aplicación arranca en modo degradado y lo dice.
npm run correspondencias:build # regenerarlo a mano (~2 min)En un hosting sin SSH no se puede lanzar ese comando, así que el mismo barrido —el mismo
código, no una copia— se dispara con POST /api/regenerar, protegido por un token en cabecera.
Si el token no está configurado en el servidor, el endpoint no ejecuta nada: responde 503.
Ver .env.example.
Los nombres de parada del GTFS vienen rotos: el exportador de Avanza les mete un
ucwords() que estropea mayúsculas y abreviaturas («Av. De Valencia», «Coso N.º
54»). El operador los escribe bien en get_stops_list —el mismo endpoint que los
desvíos—, así que no se corrigen a mano: se PIDEN, en el build, y se hornean en las
paradas.
nombres:ensure corre antes de data:build (genera la tabla solo si falta), y ese
orden no es casual: data:build es quien la lee y la aplica, así que una tabla que
naciera después llegaría cuando el artefacto ya está horneado sin nombres. Hoy quedan 918
de las 934 paradas con el nombre confirmado de Avanza (un 98 %); las 16 restantes se
quedan con el del GTFS. Y lo honesto: esas 16 son las que Avanza no da porque hoy sus
líneas van desviadas fuera de ellas — mañana serán otras.
Esas 16 llevan en pantalla un aviso, «nombre sin confirmar»: ahora sale en 16 y no en todas, y por eso informa en vez de ser ruido. Y como con las correspondencias, si el operador está caído durante el build no se muere: no hay tabla, todas las paradas caen al nombre del GTFS marcado, y el build lo dice.
npm run nombres:build # regenerar la tabla a mano (~2 min)ZETABUS_DEMO=1 npm run dev
# y luego: /parada/744?fingir=caido · /linea/35?sentido=0&fingir=desviada?fingir= intercepta todas las peticiones: cero bytes hacia el operador. Sirve para ver los
casos raros —fuente caída, respuesta ilegible, línea desviada— sin esperar a que ocurran.
El servidor auto-despliega en cada git push a main (Hostinger). Pero hay un paso a mano
que no se puede saltar:
Tras cada deploy → purgar la caché del CDN. Panel de Hostinger → la web → Caché → Borrar caché.
Por qué. Next marca el HTML prerenderizado con s-maxage=31536000 (un año), y el CDN de
Hostinger lo cachea y no lo purga al desplegar. Sin purgar, sigue sirviendo el HTML viejo, que
apunta a los /_next/static/* con el hash antiguo —que el build nuevo ya borró— → la web
carga sin estilos hasta que se purga a mano. Pasó el 26/07/2026, en el primer re-deploy que lo
destapó.
Se estudió automatizarlo —bajar el s-maxage con revalidate, o purgar por API—, pero para un
proyecto cerrado que se despliega poquísimo no compensa: el revalidate metía tráfico de
prefetch de fondo a todos los visitantes, y una API de purga es un token y un action que
mantener. La purga a mano es el procedimiento oficial.
El índice de correspondencias (arriba) se regenera de madrugada. En un hosting sin SSH un cron
no puede lanzar npm run …: solo puede pedir una URL. Por eso el barrido —el mismo código, no una
copia— se dispara con POST /api/regenerar, y el cron es literalmente esto:
curl -X POST -H "Authorization: Bearer <TOKEN>" https://zetabus.antonioblanquez.es/api/regenerar<TOKEN>es el valor deZETABUS_REGEN_TOKEN, el mismo que hay en el servidor. Va en la cabecera, nunca en la URL —que se queda en los logs—. Sin él, o con uno de menos de 32 caracteres, el endpoint responde503y no ejecuta nada (falla cerrado); con uno equivocado,401.- Dónde se configura: en el panel de Hostinger (la cuenta de Linaje), en su programador de tareas. No vive en el repo: es configuración del hosting, no del código.
- Horario:
0 2 * * *(a las 02:00). Es un valor operativo del panel, no derivable de aquí. - Responde
202al instante y trabaja de fondo (~2 min): el cron no espera a que termine, así que no lo mata ningún timeout intermedio. Si dos peticiones se solapan, la segunda recibe409y no lanza un segundo barrido. - Cómo saber que corrió: el barrido no deja recibo —un cron mal puesto no deja rastro en el
endpoint—. El resultado se mira donde vive el dato:
/api/diag→correspondencias, que traeedadSegundos(la edad del índice) ydegradado. Si la edad no se reinicia cada madrugada, el cron no está corriendo. - Y si un día NO corre — la decisión (E-02): se acepta que pueda fallar en silencio, y no se
monta alerta. Por tres motivos: (1) la señal ya existe —además de
/api/diag, el panel/estadose pone ámbar a las 26 h (el umbralFRESCURA_MAX_HORAS), así que un despiste de una noche se ve solo—; (2) la degradación es grácil: si no corre, se sirve el índice anterior y los desvíos van un día por detrás —limitación ya explicada en/sobre-los-datos—; y (3) una alerta es, ella misma, una pieza que puede fallar en silencio —¿quién avisa cuando el que avisa deja de avisar?— y una pieza móvil más en un proyecto que se quiere dejar quieto una temporada. ⇒ Si un día sospechas, míralo aquí:/estado(el ámbar) y/api/diag→correspondencias.edadSegundos.
Cuatro cosas que no se ven en las capturas y explican el resto:
1 · La auditoría vino antes que el código. Siete fases de investigación de fuentes, con sus
informes en docs/auditoria/, antes de escribir la aplicación. Tres de
ellos son retractaciones de un informe anterior propio.
2 · Las pruebas miran la pantalla, no el código. Más de 470 pruebas de motor (Vitest) y más
de 800 de navegador (Playwright) —dicho como suelo, y a propósito: contarlas exactas exige
correr las dos suites, así que ningún guardián puede vigilar esa cifra y un número redondo se
quedaría rancio en silencio—. Las visuales miden el píxel pintado, no la clase de CSS. El motivo
está escrito en e2e/lib/medir.ts: «verificar el JSON con curl NO ES haber
mirado la pantalla».
3 · Lo que se descubre se escribe, aunque duela. Cada informe de auditoría trae una sección de
lo que no se pudo comprobar, y las lecciones de método están en
docs/LECCIONES.md (L1–L9) y, de la L10 en adelante, en el registro vivo de
ZETABUS-ESTADO.md — incluidas las que se aprendieron equivocándose.
4 · Y se verificó desde fuera. Ya desplegada, el 01/08/2026 (commit ae14ea9) pasó una batería de
herramientas de terceros —PageSpeed, el validador del W3C, securityheaders, Rich Results—: ninguna nota
bajó y dos páginas pasaron de tener errores a cero. El registro completo, con las capturas y con lo que
esas pruebas no cubren, en docs/auditoriafinal/VERIFICACION-EXTERNA.md.
✅ Hoy: llegadas en vivo con posición GPS, ficha de vehículo, recorrido real con desvíos derivados, horarios de terminal, transbordos por parada, buscador, panel de estado del propio servicio (cuánto se le pide al operador y con qué frescura), modo demo — y en marcha en zetabus.antonioblanquez.es. Todo lo que incluye esta primera versión está en el CHANGELOG.
Previsto, sin fechas comprometidas:
- Avisos de parada suprimida en la vista de parada, no solo en la de línea.
- Tranvía. El núcleo ya está preparado para ser multimodal, y hay una prueba que lo vigila.
Código: Apache 2.0 · © 2026 Antonio Blánquez Cabeza — antonioblanquez.es
Los datos y las dependencias de terceros conservan sus propias condiciones, una por una, en
THIRD-PARTY-NOTICES.md.
⚠️ Una de ellas no es como las demás:react-leafletestá bajo Hippocratic 2.1, que no es una licencia aprobada por la OSI y añade una restricción de uso que Apache 2.0 no impone. No impide nada, pero conviene saberlo antes de tomar este código:THIRD-PARTY-NOTICES.md§ 5.2.
Datos de transporte procesados a partir del GTFS publicado por Avanza Zaragoza S.A.U. en el Punto de Acceso Nacional. Powered by MITRAMS. Cartografía © colaboradores de OpenStreetMap.
No es un producto oficial de Avanza Zaragoza ni del Ayuntamiento de Zaragoza.



