Integración externa - Transporte en tren y autobús

Hola a todos,

me dejé convencer después de leer las buenas sugerencias de ayuda de @prohand y @guim31 aquí.

Dicho y hecho. Mediante un Docker pude encontrar los datos de tráfico para las paradas alemanas, luego simplemente busqué con ciudades en Suiza (funciona), ciudades en Austria (funciona) y luego me volví muy atrevido, busqué una ciudad francesa en la costa atlántica, muy lejos del área de habla alemana… y mira, también funciona.

Mi script desarrollado sigue un camino especial. A través de la geolocalización del usuario en su casa en Gladys Assistant, tengo los datos geográficos.
Mediante estos datos geográficos se inicia una búsqueda de radio, primero se encuentra la parada de autobús más cercana, luego el tranvía más cercano y finalmente la estación de tren más cercana.

En el siguiente paso, se muestran directamente las cinco salidas siguientes en el tiempo.

Así, el usuario sabe en cada llamada de Gladys Assistant qué sale cuándo y adónde a continuación.

Aquí hay una salida de prueba de La Rochelle:

[/share/Container] # /share/Container/abfahrten-ortstest.sh 46.1540 -1.1450
Buscando paradas alrededor de 46.1540, -1.1450 ...
Selección: BUS - Gare La Rochelle - De Gaulle (aprox. 120 m)
No se encontró parada para TRAM.
Selección: RAIL - La Rochelle (aprox. 156 m)
No se encontró parada para FERRY.

Llamando a las salidas para los IDs de parada seleccionados ...


LAS PRÓXIMAS 5 SALIDAS EN TOTAL
Hora   | Parada                      | Tipo  | Línea    | Destino                      | Datos     | Plataforma

2026-10-12T19:57:00Z | Gare La Rochelle - De Gaulle  | BUS   | 3        | Lagord - P+R Les Greffières  | Horario  | -
2026-10-12T20:02:00Z | Gare La Rochelle - De Gaulle  | BUS   | 3        | Cottes Mailles - P+R Simone Veil | Horario  | -
2026-10-12T20:03:00Z | La Rochelle                   | TREN  | TER 864767 |                               | Horario  | -
2026-10-13T03:28:00Z | La Rochelle                   | TREN  | TER 864801 |                               | Horario  | -
2026-10-13T03:36:00Z | Gare La Rochelle - De Gaulle  | BUS   | 3        | Lagord - P+R Les Greffières  | Horario  | -

TRAM* significa: Transitous informó OTHER en un ID de parada de tranvía seleccionado.
Las 5 entradas mostradas están limitadas a los IDs de parada consultados.
Las paradas especiales sin salidas en la primera búsqueda de la API pueden faltar.

Pero ahora tengo un problema:
No puedo evaluar la lógica de las paradas de salida francesas, suizas o austriacas y, por lo tanto, busco probadores.

En realidad, solo hay que instalar un Docker. YAML está listo. Luego, iniciar el script mediante SSH y verificar los resultados y devolverme cualquier comentario.

¿Quién tiene ganas?
Simplemente avísame.

Si la lógica es correcta, entonces se procede a la integración.

Saludos
Tom

Aquí hay más salidas de prueba para leer:

Austria:

[/share/Container] # /share/Container/abfahrten-ortstest.sh 47.7260 16.2140
Buscando paradas alrededor de 47.7260, 16.2140 ...
Selección: BUS - Bad Erlach Gemeindeamt (aprox. 182 m)
No se encontró parada para TRAM.
Selección: RAIL - Bad Erlach Bahnhof (aprox. 620 m)
No se encontró parada para FERRY.

Llamando a las salidas para los IDs de parada seleccionados ...

LAS PRÓXIMAS 5 SALIDAS EN TOTAL
Hora   | Parada                      | Tipo  | Línea    | Destino                      | Datos     | Plataforma

2026-10-12T19:49:00Z | Bad Erlach Bahnhof            | TREN  | R 2775   | Friedberg                     | Horario  | -
2026-10-12T20:11:00Z | Bad Erlach Bahnhof            | TREN  | R 2776   | Wiener Neustadt Hauptbahnhof  | Horario  | -
2026-10-12T20:49:00Z | Bad Erlach Bahnhof            | TREN  | R 2779   | Friedberg                     | Horario  | -
2026-10-12T21:49:00Z | Bad Erlach Bahnhof            | TREN  | R 2783   | Aspang Markt Bahnhof          | Horario  | -
2026-10-13T02:13:00Z | Bad Erlach Bahnhof            | TREN  | R 2700   | Wiener Neustadt Hauptbahnhof  | Horario  | -


Suiza:

[/share/Container] # /share/Container/abfahrten-ortstest.sh 47.1240 9.3120
Buscando paradas alrededor de 47.1240, 9.3120 ...
Selección: BUS - Walenstadt, Rathausplatz (aprox. 216 m)
No se encontró parada para TRAM.
Selección: RAIL - Walenstadt (aprox. 476 m)
No se encontró parada para FERRY.

Llamando a las salidas para los IDs de parada seleccionados ...



LAS PRÓXIMAS 5 SALIDAS EN TOTAL
Hora   | Parada                      | Tipo  | Línea    | Destino                      | Datos     | Plataforma

2026-10-12T19:44:00Z | Walenstadt                    | TREN  | IR35     | Zürich HB                    | Horario  | 4
2026-10-12T19:46:00Z | Walenstadt                    | S-TREN | S17      | Sargans                       | Horario  | 3
2026-10-12T19:50:00Z | Walenstadt, Bahnhof           | BUS   | 444      | Murg, Bahnhof                 | Horario  | A
2026-10-12T20:13:00Z | Walenstadt                    | S-TREN | S17      | Rapperswil SG                 | Tiempo real  | 4
2026-10-12T20:15:00Z | Walenstadt                    | TREN  | IR35     | Chur                          | Horario  | 3

o caso especial de Hamburgo (transporte con ferry):

LAS PRÓXIMAS 5 SALIDAS EN TOTAL
Hora   | Parada                      | Tipo  | Línea    | Destino                      | Datos     | Plataforma

2026-10-12T19:09:00Z | Stadthausbrücke              | TREN  | S1       | Hamburg Airport (Flughafen)   | Tiempo real  | -
2026-10-12T19:10:00Z | Arningstraße                 | FERRY | 72       | Landungsbrücken              | Tiempo real  | -
2026-10-12T19:11:00Z | Stadthausbrücke              | TREN  | S3       | Pinneberg                     | Tiempo real  | -
2026-10-12T19:11:00Z | Stadthausbrücke              | TREN  | S3       | Neugraben                     | Tiempo real  | -
2026-10-12T19:14:00Z | Stadthausbrücke              | TREN  | S1       | Hamburg Airport (Flughafen)   | Tiempo real  | -

Te lo diré en la integración directa, pero si quieres resultados antes, no hay problema.

Dime cómo hacer la prueba y lo haré con gusto.

¡Hola Tom :waving_hand:

¡Partiendo hacia Francia! Vivo cerca de Toulouse, así que probé tu lógica directamente en la API Transitous: una veintena de lugares (Toulouse y sus alrededores, Burdeos, París, Lyon, Marsella, Nantes, Lille, Grenoble, La Rochelle). Buena noticia: los datos son correctos, los horarios de Tisséo, SNCF y RER coinciden. Algunos puntos sobre la lógica:

  • La búsqueda alrededor del punto solo devuelve 5 paradas (si pasas por reverse-geocode). Tan pronto como hay 5 paradas de autobús más cercanas, el tranvía y la estación desaparecen: en el Vieux-Port de Marsella, se pierde el tranvía y Saint-Charles, en Grenoble y Muret la estación. Con map/stops en una zona de 1 km, se recuperan todas las paradas y funciona.
  • Destino: usa tripTo.name. En la SNCF, headsign está vacío, y para el RER contiene un código de misión (« ZUMO », « ERTE »…), no el destino.
  • Falta el metro: añade SUBWAY (y AERIAL_LIFT para el teleférico de Toulouse :grinning_face_with_smiling_eyes:).
  • Cancelaciones: la API marca los viajes cancelados (cancelled). En Toulouse esta noche, 3 tranvías de 11 lo estaban, y tu tabla los mostraría como normales.
  • Retrasos: departure frente a scheduledDeparture, esto permite mostrar « +5 min ».
  • Horas en UTC: cada parada tiene su zona horaria (tz), que hay que convertir a hora local.
  • Paradas fantasma sin ningún modo: ignorarlas.
  • Top 5 global: en la ciudad, el metro ocupa los 5 puestos. N salidas por modo, sería mejor.
  • La parada más cercana no siempre es la correcta: en Capitole, es una parada solo atendida por una lanzadera (próxima salida mañana por la mañana). Dejar que el usuario elija sus paradas, con la búsqueda automática como sugerencia, sería genial.

Pequeña reserva honesta: la SNCF no tiene tiempo real en Transitous, así que los días de huelga veremos trenes que no circulan :sweat_smile:

Si compartes tu script, lo vuelvo a ejecutar en los mismos lugares para comparar :folded_hands:

Sobre 1: Lo he tenido en cuenta, es decir, se toma la primera parada más cercana. Es decir, la parada de autobús más cercana, luego se sigue buscando hasta la primera parada de TRAM más cercana y luego hasta la primera estación de tren más cercana.
Es decir: Si en la búsqueda hay cinco paradas de autobús y solo una estación de tren, el resultado proporciona una parada de autobús y una estación de tren.
Sobre 2: Objetivo… lo tomaré, un teleférico es un ejemplo extremo, pero con mucho gusto.
Sobre 3: SUBWAY está bien, lo tomaré.

Sobre 4: Cancelaciones… sí, lo tengo en mente, pero hoy estaba ocupado con la estructura básica del script. Eso vendrá después para revisión.

Sobre 5: Paradas fantasma: Parece ser una especialidad francesa, en Alemania existe un directorio central de paradas (hasta hoy tampoco lo sabía), donde todas las empresas de transporte registran sus paradas atendidas.

Sobre 6: Buena sugerencia, actualmente estoy trabajando en el momento adecuado. Probablemente en la sección de configuración de la integración dejaré que el usuario elija: ¿Qué quiere ver (autobús, tren, tranvía, ferry, avión) y hacer configurable el área de búsqueda.

Aquí está mi script hasta el estado actual:
El script se ejecuta contra mi Docker en el que está la API.

Aquí está mi script, tal vez se te ocurra algo más, ahora voy a tomar una Piña Colada después de 10 horas:

#!/bin/sh

Encontrar paradas cercanas y combinar las próximas salidas.

Llamada: abfahrten-ortstest.sh LATITUD LONGITUD [RADIO_METROS]

set -eu

API="https://api.transitous.org/api/v6/stoptimes"
UA=""

if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then
echo "Llamada: $0 LATITUD LONGITUD [RADIO_METROS]" >&2
exit 2
fi

LAT=$1
LON=$2
RADIUS=${3:-1200}

for V in "$LAT" "$LON" "$RADIUS"; do
case "$V" in
''|[!0-9.-]|.|-|..)
echo "Valor numérico no válido: $V" >&2
exit 2
;;
esac
done

TMP="/tmp/abfahrten-ortstest.$$"
RAW="${TMP}.json"
ROWS="${TMP}.tsv"
QUERIES="${TMP}.queries"
RESP="${TMP}.responses"
OUT="${TMP}.out"
: > "$QUERIES"
: > "$RESP"
: > "$OUT"
trap 'rm -f "$RAW" "$ROWS" "$QUERIES" "$RESP" "$OUT" "${TMP}.one"' EXIT HUP INT TERM

echo "Buscando paradas alrededor de ${LAT}, ${LON} ..."

if ! curl -fsS --get "$API" 
--data-urlencode "center=${LAT},${LON}" 
--data-urlencode "radius=${RADIUS}" 
--data-urlencode "n=100" 
--data-urlencode "language=de" 
-H "User-Agent: $UA" -o "$RAW"; then
echo "Error al buscar paradas." >&2
exit 1
fi

if ! jq -e '.stopTimes | type == "array"' "$RAW" >/dev/null 2>&1; then
echo "Respuesta inesperada de la API:"
jq . "$RAW" 2>/dev/null || cat "$RAW"
exit 1
fi

Aproximación de distancia para ordenar paradas cercanas.

jq -r --arg lat "$LAT" --arg lon "$LON" '
def cat:
if . == "BUS" then "BUS"
elif . == "FERRY" then "FERRY"
elif . == "TRAM" then "TRAM"
elif . == "SUBURBAN" or . == "REGIONAL_RAIL" then "RAIL"
else "OTHER:" + tostring
end;
def dist:
(((.place.lat|tonumber)-($lat|tonumber))111000) as $dy
| (((.place.lon|tonumber)-($lon|tonumber))72000) as $dx
| (($dx$dx+$dy$dy)|sqrt);
[
.stopTimes

| select(.place.name != null and .place.stopId != null)
| select(.place.lat != null and .place.lon != null)
| {
name:.place.name,
id:.place.stopId,
mode:(.mode // "UNKNOWN"),
category:((.mode // "UNKNOWN")|cat),
distance:dist
}
]
| unique_by([.name,.id,.mode])
| group_by([.name,.category])
| map({
name:.[0].name,
category:.[0].category,
distance:(map(.distance)|min),
modes:(map(.mode)|unique|join(",")),
ids:(map(.id)|unique)
})
| sort_by(.distance)
| .

| [.category,(.distance|floor|tostring),.name,.modes,(.ids|join("|"))]
| @tsv
' "$RAW" > "$ROWS"

if [ ! -s "$ROWS" ]; then
echo "No se encontraron paradas. ¿Aumentar el radio?"
exit 0
fi

Seleccionar el grupo más cercano por categoría.

for CAT in BUS TRAM RAIL FERRY; do
LINE=$(awk -F '\t' -v cat="$CAT" '$1 == cat {print; exit}' "$ROWS")
if [ -z "$LINE" ]; then
echo "No se encontró ninguna parada para $CAT."
continue
fi

NAME=$(printf '%s\n' "$LINE" | cut -f3)
DIST=$(printf '%s\n' "$LINE" | cut -f2)
IDS=$(printf '%s\n' "$LINE" | cut -f5)

echo "Selección: $CAT - $NAME (aprox. ${DIST} m)"

OLD_IFS=$IFS
IFS='|'
for SID in $IDS; do
    printf '%s\t%s\t%s\n' "$CAT" "$NAME" "$SID" >> "$QUERIES"
done
IFS=$OLD_IFS

done

if [ ! -s "$QUERIES" ]; then
echo "No hay IDs de parada para consultar."
exit 0
fi

echo
echo "Consultando salidas para las IDs de parada seleccionadas ..."

Consultar cada ID de parada individualmente. Los datos se combinan después.

while IFS="$(printf '\t')" read -r CAT STOPNAME SID; do
[ -n "$SID" ] || continue
ONE="${TMP}.one"

if curl -fsS --get "$API" \
    --data-urlencode "stopId=$SID" \
    --data-urlencode "n=40" \
    --data-urlencode "language=de" \
    -H "User-Agent: $UA" -o "$ONE"; then

    if jq -e '.stopTimes | type == "array"' "$ONE" >/dev/null 2>&1; then
        jq -c --arg cat "$CAT" --arg stopname "$STOPNAME" --arg sid "$SID" '
          .stopTimes[]
          | {
              category:$cat,
              stop:(.place.name // $stopname),
              stopId:(.place.stopId // $sid),
              mode:(.mode // "UNKNOWN"),
              line:(.displayName // "?"),
              destination:(.headsign // "?"),
              departure:(.place.departure // .departure // null),
              scheduled:(.place.scheduledDeparture // .scheduledDeparture // null),
              track:(.place.track // "-"),
              realtime:(.realTime // false),
              cancelled:(.cancelled // false),
              trip:(.tripId // "")
            }
        ' "$ONE" >> "$RESP"
    else
        echo "Advertencia: Formato inesperado para la ID de parada $SID" >&2
    fi
else
    echo "Advertencia: Consulta fallida para la ID de parada $SID" >&2
fi

done < "$QUERIES"

if [ ! -s "$RESP" ]; then
echo "No se recibieron salidas para las IDs de parada seleccionadas."
echo "Posiblemente la API usa un parámetro de consulta diferente."
echo "La búsqueda de paradas en sí fue exitosa."
exit 0
fi

Normalizar tiempos para ordenar.

Las fechas ISO se ordenan lexicográficamente; la hora se muestra localmente.

jq -s -r '
def rawtime:
if .departure != null then .departure
else .scheduled
end;
def clock:
if type == "string" then
(if length >= 20 and .[10:11] == "T" then
(try (("date -d @" + (.[0:19] + "Z" | fromdateiso8601 | tostring) + " +%H:%M") | .) catch "??:??")
elif length >= 5 then .[0:5]
else tostring
end)
elif type == "number" then
(try (./1000 | strftime("%H:%M")) catch tostring)
else "??:??"
end;
map(select(rawtime != null))
| map(. + {
sorttime:(rawtime|tostring),
shownTime:(rawtime|clock),
status:(if .cancelled then "CANCELADO"
elif .realtime then "TIEMPO REAL"
else "HORARIO" end),
shownMode:(if .mode == "BUS" then "BUS"
elif .mode == "FERRY" then "FERRY"
elif .mode == "TRAM" then "TRAM"
elif .mode == "SUBWAY" then "METRO"
elif .mode == "SUBURBAN" then "S-BAHN"
elif (.mode == "HIGHSPEED_RAIL" or .mode == "HIGH_SPEED_RAIL"
or .mode == "LONG_DISTANCE") then "LARGA DISTANCIA"
elif (.mode == "REGIONAL_RAIL" or .mode == "REGIONAL_FAST_RAIL"
or .mode == "RAIL") then "TREN"
else .mode end)
})

Preferir el modo de transporte explícito antes de OTHER en mensajes duplicados del mismo viaje en el mismo andén.

| sort_by([.sorttime, (if .mode == "OTHER" then 1 else 0 end)])
| unique_by([.stopId,.trip,.sorttime])
| sort_by(.sorttime)
| .[:5]
| .

| [
rawtime,
.stop,
.shownMode,
.line,
.destination,
.status,
.track
]
| @tsv
' "$RESP" > "$OUT"

echo
echo "LAS PRÓXIMAS 5 SALIDAS EN TOTAL"
printf '%-6s | %-29s | %-5s | %-8s | %-29s | %-9s | %s\n' 
"Hora" "Parada" "Tipo" "Línea" "Destino" "Datos" "Andén"
printf '%s\n' "---------------------------------------------------------------------------------------------------------------"

if [ -s "$OUT" ]; then
awk -F '\t' '{
printf "%-6s | %-29s | %-5s | %-8s | %-29s | %-9s | %s\n",
$1,$2,$3,$4,$5,$6,$7
}' "$OUT"
else
echo "No se encontraron salidas con tiempo utilizable."
fi

echo
echo "TRAM* significa: Transitous informó OTHER en una ID de parada de tranvía seleccionada."
echo "Las 5 entradas mostradas están limitadas a las IDs de parada consultadas."
echo "Las paradas especiales sin salidas en la primera búsqueda de la API pueden faltar."

Para un lugar elegido al azar en Toulouse, obtengo la siguiente salida;

[/share/Container] # /share/Container/abfahrten-ortstest.sh 43.581972 1.448832
Buscando paradas alrededor de 43.581972, 1.448832 ...
Selección: BUS - Récollets Daste (aprox. 440 m)
No se encontró ninguna parada de TRAM.
No se encontró ninguna parada de RAIL.
No se encontró ninguna parada de FERRY.

Obteniendo salidas para los IDs de parada seleccionados ...

LAS PRÓXIMAS 5 SALIDAS EN TOTAL
Hora   | Parada                      | Tipo  | Línea    | Destino                     | Datos     | Plataforma

2026-10-12T20:49:00Z | Récollets Daste              | BUS   | L4       | Cours Dillon                  | Tiempo real  | -
2026-10-12T21:19:00Z | Récollets Daste              | BUS   | L4       | Cours Dillon                  | Tiempo real  | -
2026-10-12T21:47:00Z | Récollets Daste              | BUS   | L4       | Cours Dillon                  | Tiempo real  | -
2026-10-12T22:16:00Z | Récollets Daste              | BUS   | L4       | Cours Dillon                  | Tiempo real  | -
2026-10-12T22:46:00Z | Récollets Daste              | BUS   | L4       | Cours Dillon                  | Tiempo real  | -


¿Es correcto? Ni idea. ¿Cerebro hecho puré? Garantizado… la piña colada me llama.:joy:

¡Gracias, Tom! 10 horas seguidas, la Piña Colada está más que merecida :tropical_drink: ¡Disfrútala y relájate, el script puede esperar hasta mañana :grinning_face_with_smiling_eyes:

He revisado tu script y he vuelto a hacer tu prueba de Toulouse, esto es lo que he visto (para leer con la mente despejada ^^):

Tu resultado de Toulouse es correcto: la L4 hacia Cours Dillon pasa a las 22:49, 23:19, 23:47, 00:16 y 00:46. Sin embargo, está mostrado en UTC (20:49Z = 22:49 en nuestra zona).

Pero tu punto está al lado de Saint-Agne: autobús a 160 m, metro B a 230 m, estación TER a 300 m. El script tomó un autobús a 440 m y no encontró ni el metro ni la estación. Dos razones:

  • La búsqueda pasa por los 100 próximos salidas de la zona. En Toulouse a las 22:49, esto solo cubre media hora (22:49 → 23:17). Una parada que no tiene nada en esa media hora es invisible: una estación TER con un tren por hora, o un autobús por la noche. Para encontrar las paradas sin depender de los horarios, /api/v1/map/stops en un rectángulo alrededor del punto devuelve todas las paradas de la zona. Luego, stoptimes por parada como ya haces.
  • **En cat, el metro (SUBWAY), el TGV y los Intercités (HIGHSPEED_RAIL, LONG_DISTANCE) caen en OTHER, y el bucle solo recorre BUS, TRAM, RAIL y FERRY. De los 95 salidas alrededor de tu punto, 58 eran metros B, todos ignorados.

Algunos detalles en el script:

  • Destino vacío: .headsign // "?" no funciona en una cadena vacía, porque en jq solo null y false activan el //. De ahí el TER sin destino en La Rochelle. .tripTo.name siempre da el término.
  • La hora: clock fabrica el texto "date -d @…" pero jq no puede ejecutar comandos, y es rawtime lo que se muestra, no shownTime. Más simple: .[0:19] + "Z" | fromdateiso8601 | strflocaltime("%H:%M") ejecutando jq con TZ= la zona horaria de la parada (campo place.tz). Solo hay que asegurarse de que el sistema tenga las zonas horarias (/usr/share/zoneinfo), si no jq se queda en UTC sin decir nada.
  • Cancelaciones: también hay tripCancelled (trayecto completo suprimido), además de cancelled.
  • Distancia: 72 000 m por grado de longitud, es correcto en Alemania, pero en Toulouse son más bien 81 000. 111320 × cos(latitud) funciona en todas partes.
  • User-Agent vacío: Transitous es un servicio comunitario, aprecian un User-Agent que identifique la aplicación (nombre + enlace del repositorio, por ejemplo).

Para las paradas fantasma: con tu método, no molestan, ya que no tienen ninguna salida. Solo filtrar si pasas por map/stops :wink: Y el teleférico, no es tan extremo, en Toulouse es una línea real que tomamos todos los días :joy:

La idea de dejar elegir los modos y el radio en la configuración, es genial :+1:

¡Buenas noches!