switch to using bahn.de api instead of bahnhof.de

This commit is contained in:
mark 2025-07-18 09:50:44 +02:00 committed by Mark
parent 8f9c90a689
commit 1d4ba20039
3 changed files with 179 additions and 169 deletions

View File

@ -1,33 +1,40 @@
use std::collections::HashMap; use chrono::NaiveDateTime;
use chrono::{DateTime, Local};
use serde::Deserialize; use serde::Deserialize;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct DeparturesMerklingen { pub struct DeparturesMerklingen {
#[serde(rename = "entries")] #[serde(rename = "entries")]
pub entries: Vec<Vec<DeparturesMerklingen1>>, pub entries: Vec<DeparturesMerklingen1>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct DeparturesMerklingen1 { pub struct DeparturesMerklingen1 {
#[serde(rename = "timeSchedule")] #[serde(rename = "zeit")]
pub time_schedule: Option<DateTime<Local>>, pub time_schedule: Option<NaiveDateTime>,
#[serde(rename = "timeDelayed")] #[serde(rename = "ezZeit")]
pub time_delayed: Option<DateTime<Local>>, pub time_delayed: Option<NaiveDateTime>,
#[serde(rename = "platform")] #[serde(rename = "ezGleis")]
pub platform: Option<String>, pub platform: Option<String>,
#[serde(rename = "platformSchedule")] #[serde(rename = "gleis")]
pub platform_schedule: Option<String>, pub platform_schedule: Option<String>,
// TODO: find this in api if it exists
#[serde(rename = "canceled")] #[serde(rename = "canceled")]
pub canceled: Option<bool>, pub canceled: Option<bool>,
#[serde(rename = "lineName")] #[serde(rename = "verkehrmittel")]
pub line_name: Option<String>, pub line_name: Option<DeparturesMerklingen4>,
// TODO: find this in api if it exists
#[serde(rename = "stopPlace")] #[serde(rename = "stopPlace")]
pub stop_place: Option<DeparturesMerklingen2>, pub stop_place: Option<DeparturesMerklingen2>,
#[serde(rename = "destination")] #[serde(rename = "terminus")]
pub destination: Option<DeparturesMerklingen2>, pub destination: Option<String>,
#[serde(rename = "messages")] #[serde(rename = "meldungen")]
pub messages: Option<HashMap<String, Vec<DeparturesMerklingen3>>>, pub messages: Option<Vec<DeparturesMerklingen3>>,
}
#[derive(Deserialize)]
pub struct DeparturesMerklingen4 {
#[serde(rename = "linienNummer")]
pub line_number: Option<String>,
#[serde(rename = "kurzText")]
pub product: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct DeparturesMerklingen2 { pub struct DeparturesMerklingen2 {
@ -40,22 +47,20 @@ pub struct DeparturesMerklingen2 {
pub struct DeparturesMerklingen3 { pub struct DeparturesMerklingen3 {
#[serde(rename = "text")] #[serde(rename = "text")]
pub text: Option<String>, pub text: Option<String>,
#[serde(rename = "important")] #[serde(rename = "prioritaet")]
pub important: Option<bool>, pub priority: Option<String>,
} }
pub async fn departures_merklingen() -> Result<DeparturesMerklingen, String> { pub async fn departures_merklingen() -> Result<DeparturesMerklingen, String> {
let url = format!( let url = r#"https://www.bahn.de/web/api/reiseloesung/abfahrten?ortExtId=8003983&mitVias=true&maxVias=8&verkehrsmittel[]=ICE&verkehrsmittel[]=EC_IC&verkehrsmittel[]=IR&verkehrsmittel[]=REGIONAL&verkehrsmittel[]=SBAHN"#;
r#"https://www.bahnhof.de/api/boards/departures?evaNumbers=8003983&filterTransports=REGIONAL_TRAIN&duration=120&stationCategory=5&locale=de&sortBy=TIME_SCHEDULE"# match reqwest::get(url).await {
);
match reqwest::get(&url).await {
Ok(response) => match response.text().await { Ok(response) => match response.text().await {
Ok(response) => { Ok(response) => match serde_json::from_str::<DeparturesMerklingen>(&response) {
match serde_json::from_str::<DeparturesMerklingen>(&response) { Ok(response) => Ok(response),
Ok(response) => Ok(response), Err(e) => Err(format!(
Err(e) => Err(format!("Couldn't parse HTTP response from URL {url:?}: {e}\nResponse was (raw):\n{response}"))?, "Couldn't parse HTTP response from URL {url:?}: {e}\nResponse was (raw):\n{response}"
} ))?,
} },
Err(e) => Err(format!("Couldn't get HTTP response from URL {url:?}: {e}"))?, Err(e) => Err(format!("Couldn't get HTTP response from URL {url:?}: {e}"))?,
}, },
Err(e) => Err(format!("Couldn't make GET request to URL {url:?}: {e}"))?, Err(e) => Err(format!("Couldn't make GET request to URL {url:?}: {e}"))?,

View File

@ -36,7 +36,7 @@ async function con(change) {
nmv('oberdrackenstein_rathaus', 26) nmv('oberdrackenstein_rathaus', 26)
break; break;
case "967UDrackensteinKirche": case "967UDrackensteinKirche":
nmv('unterdrackenstain_kirche', 32) nmv('unterdrackenstein_kirche', 32)
break; break;
case "967GosbachEinkaufszentrum": case "967GosbachEinkaufszentrum":
nmv('gosbach_einkaufszentrum', 37) nmv('gosbach_einkaufszentrum', 37)
@ -72,7 +72,7 @@ async function con(change) {
<button id="hohenstadt_kirche" onclick="con('967HohenstadtKirche')">Hohenstadt<small> Kirche</small></button> / <button id="hohenstadt_kirche" onclick="con('967HohenstadtKirche')">Hohenstadt<small> Kirche</small></button> /
<button id="hohenstadt_waltertal" onclick="con('967HohenstadtWaltertal')">Hohenstadt<small> Abzw. Waltertal</small></button> / <button id="hohenstadt_waltertal" onclick="con('967HohenstadtWaltertal')">Hohenstadt<small> Abzw. Waltertal</small></button> /
<button id="oberdrackenstein_rathaus" onclick="con('967ODrackensteinRathaus')">Oberdrackenstein<small> Rathaus</small></button> / <button id="oberdrackenstein_rathaus" onclick="con('967ODrackensteinRathaus')">Oberdrackenstein<small> Rathaus</small></button> /
<button id="unterdrackenstain_kirche" onclick="con('967UDrackensteinKirche')">Unterdrackenstein<small> Kirche</small></button> / <button id="unterdrackenstein_kirche" onclick="con('967UDrackensteinKirche')">Unterdrackenstein<small> Kirche</small></button> /
<button id="gosbach_einkaufszentrum" onclick="con('967GosbachEinkaufszentrum')">Gosbach<small> Einkaufszentrum</small></button> / <button id="gosbach_einkaufszentrum" onclick="con('967GosbachEinkaufszentrum')">Gosbach<small> Einkaufszentrum</small></button> /
<button id="gosbach_lamm" onclick="con('967GosbachLamm')">Gosbach<small> Lamm</small></button> / <button id="gosbach_lamm" onclick="con('967GosbachLamm')">Gosbach<small> Lamm</small></button> /
<button id="gosbach_abzw_drackenstein" onclick="con('967GosbachDrackenstein')">Gosbach<small> Abzw. Drackenstein</small></button> / <button id="gosbach_abzw_drackenstein" onclick="con('967GosbachDrackenstein')">Gosbach<small> Abzw. Drackenstein</small></button> /
@ -90,7 +90,7 @@ async function con(change) {
Diese Seite soll eine einfache Möglichkeit darstellen, zu prüfen, Diese Seite soll eine einfache Möglichkeit darstellen, zu prüfen,
ob der Bus und Zug von/nach Merklingen heute kommt, oder ob er mal wieder streikt oder aus sonstigen Gründen fehlt.<br> ob der Bus und Zug von/nach Merklingen heute kommt, oder ob er mal wieder streikt oder aus sonstigen Gründen fehlt.<br>
Das ganze sollte aber auch für andere Busse funktionieren, sofern der VVS den Bus kennt (→ <a href="https://www.vvs.de/">vvs.de</a>).<br> Das ganze sollte aber auch für andere Busse funktionieren, sofern der VVS den Bus kennt (→ <a href="https://www.vvs.de/">vvs.de</a>).<br>
Zug-Informationen kommen von <a href="https://www.bahnhof.de/merklingen-schwaebische-alb/abfahrt">bahnhof.de</a>. Zug-Informationen kommen von <a href="https://www.bahn.de/buchung/abfahrten-ankuenfte">bahn.de</a>.
</div> </div>
<div><a href="./api">API documentation</a></div> <div><a href="./api">API documentation</a></div>
</body></html> </body></html>

View File

@ -14,7 +14,7 @@ use tokio::{
time::{Instant, sleep}, time::{Instant, sleep},
}; };
use crate::vvs::VvsStopIdentifier; use crate::{bahnhof::DeparturesMerklingen3, vvs::VvsStopIdentifier};
#[rocket::launch] #[rocket::launch]
async fn rocket() -> _ { async fn rocket() -> _ {
@ -103,164 +103,169 @@ async fn index(
let mut table_rows = BTreeMap::<_, (String, String, usize, usize, bool)>::new(); let mut table_rows = BTreeMap::<_, (String, String, usize, usize, bool)>::new();
for departure in &departures.entries { for departure in &departures.entries {
let mut messages = vec![]; let mut messages = vec![];
for departure in departure.iter() { if let Some(message) = &departure.messages {
if let Some(message) = &departure.messages { for DeparturesMerklingen3 { priority, text } in message {
for (_message_type, message) in message { if let Some(text) = &text {
for message in message { if priority
if let Some(text) = message.text.clone() { .as_ref()
if message.important.is_some_and(|v| v) { .is_some_and(|v| ["HOCH"].contains(&v.to_uppercase().as_str()))
if !messages.contains(&text) { {
messages.push(text); if !messages.contains(text) {
} messages.push(text.to_owned());
}
} }
} }
} }
} }
} }
if let Some(departure) = departure.first() { if let Some(time) = departure.time_delayed.or(departure.time_schedule) {
if let Some(time) = departure.time_delayed.or(departure.time_schedule) { let row = table_rows
let row = table_rows .entry((time.date(), time.hour(), time.minute() >= 30))
.entry((time.date_naive(), time.hour(), time.minute() >= 30)) .or_default();
.or_default(); let line_name = departure
let line_name = departure .line_name
.line_name .as_ref()
.as_ref() .map(|v| {
.map(|v| v.as_str()) v.product.as_ref().map(|prod| {
.unwrap_or("[RE?]"); format!(
let is_ulm = { "{prod} {}",
if let Some(destination) = departure.destination.as_ref() { v.line_number.as_ref().map(|v| v.as_str()).unwrap_or("?")
if let Some(name) = &destination.name { )
let name = name.to_lowercase(); })
if name.starts_with("ulm") || name.contains(" ulm") { })
Some(true) .flatten()
} else if name.contains("wendlingen") .unwrap_or_else(|| "[RE ?]".to_owned());
|| name.contains("plochingen") let is_ulm = {
|| name.contains("stuttgart") if let Some(destination) = departure.destination.as_ref() {
{ let name = destination.to_lowercase();
Some(false) if name.starts_with("ulm") || name.contains(" ulm") {
} else { Some(true)
None } else if name.contains("wendlingen")
} || name.contains("plochingen")
} else { || name.contains("stuttgart")
None {
} Some(false)
} else { } else {
None None
} }
} else {
None
} }
.or_else(|| { }
match departure.platform_schedule.as_ref().map(|v| v.as_str()) { .or_else(|| {
Some("1") => Some(true), match departure.platform_schedule.as_ref().map(|v| v.as_str()) {
Some("4") => Some(false), Some("1") => Some(true),
_ => None, Some("4") => Some(false),
} _ => None,
}); }
let mut dep_str = "".to_owned(); });
dep_str.push_str(html_escape::encode_safe(line_name).as_ref()); let mut dep_str = "".to_owned();
let platform = departure dep_str.push_str(html_escape::encode_safe(&line_name).as_ref());
.platform let platform = departure
.as_ref() .platform
.map(|v| v.as_str()) .as_ref()
.or(departure.platform_schedule.as_ref().map(|v| v.as_str())); .map(|v| v.as_str())
dep_str.push_str(&format!( .or(departure.platform_schedule.as_ref().map(|v| v.as_str()));
" <small{}>{:0>2}:{:0>2}</small>{}{}", dep_str.push_str(&format!(
if let Some(schedule) = &departure.time_schedule { " <small{}>{:0>2}:{:0>2}</small>{}{}",
let delay = ({ if let Some(schedule) = &departure.time_schedule {
if time > *schedule { let delay = ({
time - *schedule if time > *schedule {
} else { time - *schedule
TimeDelta::zero()
}
}
.num_minutes() as f64
/ 15.0)
.max(0.0)
.min(1.0);
format!(
r#" style="color: hsl({}, 60%, 60%)""#,
(120u8.saturating_sub((120.0 * delay) as u8))
)
} else {
String::new()
},
time.hour(),
time.minute(),
if let Some(platform) = platform {
if platform.trim().is_empty() {
""
} else if departure
.platform_schedule
.as_ref()
.is_some_and(|scheduled| platform != scheduled)
{
r#"<small style="color:darkorange;"> auf Gleis "#
} else { } else {
r#"<small style="color:gray"> auf Gleis "# TimeDelta::zero()
} }
} else { }
.num_minutes() as f64
/ 15.0)
.max(0.0)
.min(1.0);
format!(
r#" style="color: hsl({}, 60%, 60%)""#,
(120u8.saturating_sub((120.0 * delay) as u8))
)
} else {
String::new()
},
time.hour(),
time.minute(),
if let Some(platform) = platform {
if platform.trim().is_empty() {
"" ""
}, } else if departure
if let Some(platform) = platform { .platform_schedule
if platform.trim().is_empty() { .as_ref()
String::new() .is_some_and(|scheduled| platform != scheduled)
} else { {
format!("{platform}</small>") r#"<small style="color:darkorange;"> auf Gleis "#
}
} else { } else {
String::new() r#"<small style="color:gray"> auf Gleis "#
},
));
if let Some(dest) = &departure.destination {
if let Some(name) = &dest.name {
dep_str.push_str(r#"<br><span style="color:gray;"> → </span>"#);
dep_str.push_str(html_escape::encode_safe(name).as_ref());
} }
} else {
""
},
if let Some(platform) = platform {
if platform.trim().is_empty() {
String::new()
} else {
format!("{platform}</small>")
}
} else {
String::new()
},
));
if let Some(dest) = &departure.destination {
dep_str.push_str(r#"<br><span style="color:gray;"> → </span>"#);
dep_str.push_str(html_escape::encode_safe(dest).as_ref());
}
if departure.canceled.is_some_and(|canceled| canceled) {
dep_str.push_str(
r#"<div style="color:darkorange;"> ▸ fällt aus / cancelled</div>"#,
);
} else if let Some(stop_place) = departure
.stop_place
.as_ref()
.filter(|stop_place| stop_place.canceled.is_some_and(|canceled| canceled))
{
dep_str.push_str(r#"<div style="color:darkorange;"> ▸ stop/halt "#);
html_escape::encode_safe_to_string(
stop_place
.name
.as_ref()
.map(|v| v.as_str())
.unwrap_or("[stopPlace.name]"),
&mut dep_str,
);
dep_str.push_str(": fällt aus / cancelled</div>");
}
for message in &messages {
dep_str.push_str(r#"<div style="color:darkorange;"> ▹ "#);
html_escape::encode_safe_to_string(message, &mut dep_str);
dep_str.push_str("</div>");
}
row.4 = is_ulm.is_some();
if !row.0.is_empty() {
row.0.push_str("<br>");
}
if !row.1.is_empty() {
row.1.push_str("<br>");
}
match is_ulm {
Some(true) => {
row.0.push_str(&dep_str);
row.2 += 1;
} }
if departure.canceled.is_some_and(|canceled| canceled) { Some(false) => {
dep_str.push_str( row.1.push_str(&dep_str);
r#"<div style="color:darkorange;"> ▸ fällt aus / cancelled</div>"#, row.3 += 1;
);
} else if let Some(stop_place) =
departure.stop_place.as_ref().filter(|stop_place| {
stop_place.canceled.is_some_and(|canceled| canceled)
})
{
dep_str.push_str(r#"<div style="color:darkorange;"> ▸ stop/halt "#);
html_escape::encode_safe_to_string(
stop_place
.name
.as_ref()
.map(|v| v.as_str())
.unwrap_or("[stopPlace.name]"),
&mut dep_str,
);
dep_str.push_str(": fällt aus / cancelled</div>");
} }
for message in &messages { None => {
dep_str.push_str(r#"<div style="color:darkorange;"> ▹ "#); if row.2 <= row.3 {
html_escape::encode_safe_to_string(message, &mut dep_str);
dep_str.push_str("</div>");
}
row.4 = is_ulm.is_some();
match is_ulm {
Some(true) => {
row.0.push_str(&dep_str); row.0.push_str(&dep_str);
row.2 += 1; row.2 += 1;
} } else {
Some(false) => {
row.1.push_str(&dep_str); row.1.push_str(&dep_str);
row.3 += 1; row.3 += 1;
} }
None => {
if row.2 <= row.3 {
row.0.push_str(&dep_str);
row.2 += 1;
} else {
row.1.push_str(&dep_str);
row.3 += 1;
}
}
} }
} }
} }