This commit is contained in:
mark 2025-03-10 14:24:10 +01:00
commit b56a256682
8 changed files with 3689 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

2608
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

15
Cargo.toml Normal file
View File

@ -0,0 +1,15 @@
[package]
name = "merklingen_connection_check"
version = "0.1.0"
edition = "2021"
[dependencies]
chrono = { version = "0.4.39", features = ["serde"] }
html-escape = "0.2.13"
quick-xml = { version = "0.37.2", features = ["serialize"] }
reqwest = { version = "0.12.12", features = ["charset", "h2", "http2", "macos-system-configuration", "rustls-tls"], default-features = false }
rocket = "0.5.1"
serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.138"
tokio = { version = "1.43.0", features = ["full"] }
urlencoding = "2.1.3"

74
src/api.html Normal file
View File

@ -0,0 +1,74 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="color-scheme" content="light dark">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>merklingen connection check: api</title>
<style>
</style>
</head>
<body>
<h1>merklingen connection check: api docs</h1>
note for the impatient: you can skip most of the docs and go straight to the examples, the api isn't that cryptic :)
<h2><code>./vvs/find_stop/simple/&lt;stop&gt;</code></h2>
Can be used to check which stop a certain stop query would actually use:
<a href="./vvs/find_stop/simple/oberdrackenstein">./vvs/find_stop/simple/oberdrackenstein</a><br>
Responds with <code>ok: &lt;actual name of the stop&gt;</code> or <code>err: ...</code>.
<h2><code>./vvs/mins_before[_check]/simple/&lt;from&gt;/&lt;to&gt;?year=&amp;month=&amp;day=&amp;hour=&amp;minute=&amp;interchanges=&amp;minutes=</code></h2>
<p>
Searches for connections from <code>&lt;from&gt;</code> to <code>&lt;to&gt;</code> which arrive before the specified time.
Can optionally filter by number of interchanges. Of the connections (after filtering), finds the one which departs last
and responds with how many minutes before the specified time the found connection departs at <code>&lt;from&gt;</code>.
<br>
For example, if you request to arrive at 8:50, a trip arrives at 8:45 and departs at 8:40,
then the response would be <code>minutes: 10</code>.
</p>
<p>
Replace <code>&lt;from&gt;</code> and <code>&lt;to&gt;</code> with the names of your origin and destination stops,
and add values for the missing query parameters.
<br>
<code>interchanges</code> is optional and, if present, limits the search to connections
with at most the specified number of interchanges. Setting <code>interchanges=0</code> filters out any non-direct connections.<br>
Additionally, you can add <code>&allowtaxi=true</code> to prevent the server from filtering our lines which contain "taxi" in their name (Ruftaxi/Linientaxi).
<br>
<code>minutes</code> is required by <code>mins_before_check</code> and is not used by <code>mins_before</code>.
It limits how many minutes before the specified time, at most, you want to depart. If <code>mins_before</code>
would respond with a number greater than the <code>minutes</code> parameter, <code>mins_before_check</code>
responds with an error (410 Gone) instead, if <code>mins_before</code> would respond with a number
smaller than or equal to the <code>minutes</code> parameter, <code>mins_before_check</code> responds
with an empty string (this should be easy to check if you can't easily access the response's http status code).
</p>
<p>
<code>mins_before</code> responds with
<ul>
<li>a 404 (Not Found) response if the origin or destination stop could not be found: <code>err: ...</code></li>
<li>a 410 (Gone) response if no connection was found: <code>gone: ...</code></li>
<li>a 200 (Ok) response if one or more connections were found: <code>minutes: &lt;minutes as an integer&gt;</code></li>
</ul>
<br>
<code>mins_before_check</code> responds with
<ul>
<li>a 404 (Not Found) response if the origin or destination stop could not be found: <code>err: ...</code></li>
<li>a 410 (Gone) response if no connection was found: <code>gone: ...</code></li>
<li>a 410 (Gone) response if all connections would take too long: <code>gone: &lt;a&gt; > &lt;b&gt; minutes</code></li>
<li>a 200 (Ok) response if one or more connections were found. in this case, the response consists of an empty string: <code> </code></li>
</ul>
<br>
If something goes wrong (especially plausible: the server's communication with the vvs api
fails in some way), you may also get a 500 (Internal Server Error) response: <code>err: ...</code>
</p>
<h3>Examples</h3>
Assuming the bus runs normally tomorrow (%DAY%.%MONTH%.%YEAR%), this should respond with an empty string:<br>
<a href="./vvs/mins_before_check/simple/oberdrackenstein%20rathaus/merklingen%20bahnhof?year=%YEAR%&month=%MONTH%&day=%DAY%&hour=8&minute=40&interchanges=0&minutes=25"><code>./vvs/mins_before_check/simple/oberdrackenstein%20rathaus/merklingen%20bahnhof?year=%YEAR%&amp;month=%MONTH%&amp;day=%DAY%&amp;hour=8&amp;minute=40&amp;interchanges=0&amp;minutes=25</code></a><br>
and this should display about 20 minutes, since the bus usually departs at 8:20, 20 minutes before the requested 8:40:<br>
<a href="./vvs/mins_before/simple/oberdrackenstein%20rathaus/merklingen%20bahnhof?year=%YEAR%&month=%MONTH%&day=%DAY%&hour=8&minute=40&interchanges=0"><code>./vvs/mins_before/simple/oberdrackenstein%20rathaus/merklingen%20bahnhof?year=%YEAR%&amp;month=%MONTH%&amp;day=%DAY%&amp;hour=8&amp;minute=40&amp;interchanges=0</code></a><br>
You can use the first case to easily check if everything is alright (empty 200 response) or not (non-empty, non-200 response).<br>
For convenience, you can also use the shorter and not date-dependent versions, which can optionally take override values for <code>hours=</code> and <code>minute=</code>:<br>
<a href="./vvs/short/in_hrs/1/mins_before_check/oberdrackenstein%20rathaus/merklingen%20bahnhof?minute=40&interchanges=0&minutes=25"><code>./vvs/short/in_hrs/2/mins_before_check/oberdrackenstein%20rathaus/merklingen%20bahnhof?minute=40&amp;interchanges=0&amp;minutes=25</code></a><br>
and
<a href="./vvs/short/in_hrs/1.5/mins_before/oberdrackenstein%20rathaus/merklingen%20bahnhof?interchanges=0"><code>./vvs/short/in_hrs/0.5/mins_before/oberdrackenstein%20rathaus/merklingen%20bahnhof?interchanges=0</code></a><br>
<h3>Usage Examples</h3>
With <code>sh</code> and <code>curl</code> (replace the echo command with whatever command you want to run if the bus may not arrive as expected):<br>
<code>curl -f 'https://tomatenmhark.org/nöpnv/vvs/short/in_hrs/1/mins_before_check/oberdrackenstein%20rathaus/merklingen%20bahnhof?minute=40&amp;interchanges=0&amp;minutes=25' || echo whoops, bus gone</code>
</body></html>

63
src/bahnhof.rs Normal file
View File

@ -0,0 +1,63 @@
use std::collections::HashMap;
use chrono::{DateTime, Local};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct DeparturesMerklingen {
#[serde(rename = "entries")]
pub entries: Vec<Vec<DeparturesMerklingen1>>,
}
#[derive(Deserialize)]
pub struct DeparturesMerklingen1 {
#[serde(rename = "timeSchedule")]
pub time_schedule: Option<DateTime<Local>>,
#[serde(rename = "timeDelayed")]
pub time_delayed: Option<DateTime<Local>>,
#[serde(rename = "platform")]
pub platform: Option<String>,
#[serde(rename = "platformSchedule")]
pub platform_schedule: Option<String>,
#[serde(rename = "canceled")]
pub canceled: Option<bool>,
#[serde(rename = "lineName")]
pub line_name: Option<String>,
#[serde(rename = "stopPlace")]
pub stop_place: Option<DeparturesMerklingen2>,
#[serde(rename = "destination")]
pub destination: Option<DeparturesMerklingen2>,
#[serde(rename = "messages")]
pub messages: Option<HashMap<String, Vec<DeparturesMerklingen3>>>,
}
#[derive(Deserialize)]
pub struct DeparturesMerklingen2 {
#[serde(rename = "name")]
pub name: Option<String>,
#[serde(rename = "canceled")]
pub canceled: Option<bool>,
}
#[derive(Deserialize)]
pub struct DeparturesMerklingen3 {
#[serde(rename = "text")]
pub text: Option<String>,
#[serde(rename = "important")]
pub important: Option<bool>,
}
pub async fn departures_merklingen() -> Result<DeparturesMerklingen, String> {
let url = format!(
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 {
Ok(response) => match response.text().await {
Ok(response) => {
match serde_json::from_str::<DeparturesMerklingen>(&response) {
Ok(response) => Ok(response),
Err(e) => Err(format!("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 make GET request to URL {url:?}: {e}"))?,
}
}

101
src/index.html Normal file
View File

@ -0,0 +1,101 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="color-scheme" content="light dark">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>merklingen connection check</title>
<style>
td {
width: 45%;
}
</style>
</head>
<body>
<h2>Merklingen <small><small>Schwäbische Alb</small></small></h2>
%DEPARTURES%
<h2>🚐 967 / 968</h2>
<script>
async function nmv(name, mins) {
document.getElementById(name).style.backgroundColor = "gray";
document.getElementById(name).style.backgroundColor = (await fetch("./vvs/short/in_hrs/15.5/mins_before_check/" + encodeURIComponent(name.replace("_", " ")) + "/merklingen%20bahnhof?hour=8&minute=40&interchanges=0&minutes=" + mins)).ok ? "green" : "red";
}
async function con(change) {
if (change === "") {
location.hash = "";
} else if (change) {
location.hash = "#" + change;
}
if (location.hash.startsWith("#")) {
switch (location.hash.substring(1)) {
case "967Widderstall":
nmv('widderstall', 15)
break;
case "967HohenstadtKirche":
nmv('hohenstadt_kirche', 20)
break;
case "967HohenstadtWaltertal":
nmv('hohenstadt_waltertal', 22)
break;
case "967ODrackensteinRathaus":
nmv('oberdrackenstein_rathaus', 26)
break;
case "967UDrackensteinKirche":
nmv('unterdrackenstain_kirche', 32)
break;
case "967GosbachEinkaufszentrum":
nmv('gosbach_einkaufszentrum', 37)
break;
case "967GosbachLamm":
nmv('gosbach_lamm', 38)
break;
case "967GosbachDrackenstein":
nmv('gosbach_abzw_drackenstein', 39)
break;
case "967GosbachWiesensteigerStr":
nmv('gosbach_wiesensteiger_str', 40)
break;
case "967MuhlhausenRathaus":
nmv('mühlhausen_im_täle_rathaus', 41)
break;
case "967WiesensteigSchontalweg":
nmv('wiesensteig_schöntalweg', 44)
break;
case "967WiesensteigBrunnengarten":
nmv('wiesensteig_brunnengarten', 45)
break;
case "967WiesensteigRathaus":
nmv('wiesensteig_rathaus', 46)
break;
}
}
}
</script>
<div>Zum Bahnhof Merklingen <small>(Ankunft 8:40 <small> | Heute wenn es noch nicht 8:30 ist, sonst wird Bus von morgen geprüft</small>)</small> von</div>
<div>
<button id="widderstall" onclick="con('967Widderstall')">Widderstall</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="oberdrackenstein_rathaus" onclick="con('967ODrackensteinRathaus')">Oberdrackenstein<small> Rathaus</small></button> /
<button id="unterdrackenstain_kirche" onclick="con('967UDrackensteinKirche')">Unterdrackenstein<small> Kirche</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_abzw_drackenstein" onclick="con('967GosbachDrackenstein')">Gosbach<small> Abzw. Drackenstein</small></button> /
<button id="gosbach_wiesensteiger_str" onclick="con('967GosbachWiesensteigerStr')">Gosbach<small> Wiesensteiger Str.</small></button> /
<button id="mühlhausen_im_täle_rathaus" onclick="con('967MuhlhausenRathaus')">Mühlhausen<small> Rathaus</small></button> /
<button id="wiesensteig_schöntalweg" onclick="con('967WiesensteigSchontalweg')">Wiesensteig<small> Schöntalweg</small></button> /
<button id="wiesensteig_brunnengarten" onclick="con('967WiesensteigBrunnengarten')">Wiesensteig<small> Brunnengarten</small></button> /
<button id="wiesensteig_rathaus" onclick="con('967WiesensteigRathaus')">Wiesensteig<small> Rathaus</small></button>
</div>
<script>con();</script>
<br><br>
<hr>
<br><br>
<p>
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>
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>.
</p>
<div><a href="./api">API documentation</a></div>
</body></html>

461
src/main.rs Normal file
View File

@ -0,0 +1,461 @@
mod bahnhof;
mod vvs;
use std::{collections::BTreeMap, sync::Arc, time::Duration};
use chrono::{Datelike, Local, TimeDelta, Timelike};
use rocket::{get, http::Status, response::content::RawHtml, routes, FromForm, State};
use tokio::{
sync::Mutex,
time::{sleep, Instant},
};
#[rocket::launch]
async fn rocket() -> _ {
rocket::build()
.manage(Arc::new(()))
.manage(Mutex::new(
Option::<(Instant, Result<bahnhof::DeparturesMerklingen, String>)>::None,
))
.mount(
"/",
routes![
index,
api_doc,
vvs_find_stop_simple,
vvs_mins_before_simple,
vvs_mins_before_check_simple,
vvs_short_in_hrs_mins_before_simple,
vvs_short_in_hrs_mins_before_check_simple,
],
)
}
#[get("/")]
async fn index(
departures: &State<Mutex<Option<(Instant, Result<bahnhof::DeparturesMerklingen, String>)>>>,
) -> RawHtml<String> {
let mut departures = departures.lock().await;
if departures.as_ref().is_none_or(|(last_updated, result)| {
last_updated.elapsed() > Duration::from_secs(30 * if result.is_ok() { 5 } else { 1 })
}) {
*departures = Some((Instant::now(), bahnhof::departures_merklingen().await))
}
let departures_str = match &*departures {
Some((_, Ok(departures))) => {
let mut departures_str = String::new();
departures_str.push_str(r#"<table style="width:100%;">"#);
let mut table_rows = BTreeMap::<_, (String, String, usize, usize)>::new();
for departure in &departures.entries {
let mut messages = vec![];
for departure in departure.iter() {
if let Some(message) = &departure.messages {
for (_message_type, message) in message {
for message in message {
if let Some(text) = message.text.clone() {
if message.important.is_some_and(|v| v) {
if !messages.contains(&text) {
messages.push(text);
}
}
}
}
}
}
}
if let Some(departure) = departure.first() {
if let Some(time) = departure.time_delayed.or(departure.time_schedule) {
let row = table_rows
.entry((time.date_naive(), time.hour(), time.minute() >= 30))
.or_default();
let line_name = departure
.line_name
.as_ref()
.map(|v| v.as_str())
.unwrap_or("[RE?]");
let is_ulm = {
if let Some(destination) = departure.destination.as_ref() {
if let Some(name) = &destination.name {
let name = name.to_lowercase();
if name.starts_with("ulm") {
Some(true)
} else if name.starts_with("wendlingen")
|| name.starts_with("plochingen")
|| name.starts_with("stuttgart")
{
Some(false)
} else {
None
}
} else {
None
}
} else {
None
}
}
.or_else(|| {
match departure.platform_schedule.as_ref().map(|v| v.as_str()) {
Some("1") => Some(true),
Some("4") => Some(false),
_ => None,
}
});
let mut dep_str = "<div>".to_owned();
dep_str.push_str(html_escape::encode_safe(line_name).as_ref());
let platform = departure
.platform
.as_ref()
.map(|v| v.as_str())
.or(departure.platform_schedule.as_ref().map(|v| v.as_str()));
dep_str.push_str(&format!(
" <small{}>{:0>2}:{:0>2}</small>{}{}",
if let Some(schedule) = &departure.time_schedule {
let delay = ({
if time > *schedule {
time - *schedule
} else {
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 {
r#"<small style="color:gray"> auf Gleis "#
}
} 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 {
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());
}
}
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>");
}
dep_str.push_str("</div>");
match is_ulm {
Some(true) => {
row.0.push_str(&dep_str);
row.2 += 1;
}
Some(false) => {
row.1.push_str(&dep_str);
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;
}
}
}
}
}
}
for ((_date, _hour, _late_half), (a, b, _, _)) in table_rows {
departures_str.push_str("<tr><td>");
departures_str.push_str(&a);
departures_str.push_str("</td><td>");
departures_str.push_str(&b);
departures_str.push_str("</td></tr>");
}
departures_str.push_str("</table>");
departures_str
}
Some((_, Err(e))) => format!("<small>{}</small>", html_escape::encode_safe(e)),
None => "<small>failed</small>".to_owned(),
};
RawHtml(include_str!("index.html").replace("%DEPARTURES%", &departures_str))
}
#[get("/api")]
async fn api_doc() -> RawHtml<String> {
let tomorrow = Local::now() + Duration::from_secs(60 * 60 * 24);
RawHtml(
include_str!("api.html")
.replace("%YEAR%", &tomorrow.year().to_string())
.replace("%MONTH%", &tomorrow.month().to_string())
.replace("%DAY%", &tomorrow.day().to_string()),
)
}
async fn rate_limit(rate_limit: &State<Arc<()>>) -> Arc<()> {
let rate_limit = Arc::clone(rate_limit);
let secs = (Arc::strong_count(&rate_limit).saturating_sub(3) as u64).saturating_mul(2);
if secs > 0 {
sleep(Duration::from_secs(secs)).await;
}
rate_limit
}
#[get("/vvs/find_stop/simple/<stop>")]
async fn vvs_find_stop_simple(stop: &str, counter: &State<Arc<()>>) -> String {
let _counter = rate_limit(counter).await;
match vvs::find_stop_best(stop).await {
Ok(Some((name, _))) => {
format!("ok: {name}")
}
Ok(None) => format!("err: not found"),
Err(e) => format!("err: {e}"),
}
}
#[derive(FromForm)]
struct VvsShortInHrsMinsBeforeSimple {
hour: Option<u8>,
minute: Option<u8>,
interchanges: Option<u16>,
allowtaxi: Option<bool>,
}
#[get("/vvs/short/in_hrs/<hrs>/mins_before/<from>/<to>?<form..>")]
async fn vvs_short_in_hrs_mins_before_simple(
hrs: f64,
from: &str,
to: &str,
form: VvsShortInHrsMinsBeforeSimple,
counter: &State<Arc<()>>,
) -> (Status, String) {
let time = Local::now() + Duration::from_secs_f64(hrs * 60.0 * 60.0);
let form = VvsMinsBeforeSimpleForm {
year: time.year() as _,
month: time.month() as _,
day: time.day() as _,
hour: form.hour.unwrap_or(time.hour() as _),
minute: form.minute.unwrap_or(time.minute() as _),
interchanges: form.interchanges,
allowtaxi: form.allowtaxi,
};
vvs_mins_before_simple_impl(from, to, form, counter).await
}
#[derive(FromForm)]
struct VvsShortInHrsMinsBeforeCheckSimple {
hour: Option<u8>,
minute: Option<u8>,
interchanges: Option<u16>,
minutes: u32,
allowtaxi: Option<bool>,
}
#[get("/vvs/short/in_hrs/<hrs>/mins_before_check/<from>/<to>?<form..>")]
async fn vvs_short_in_hrs_mins_before_check_simple(
hrs: f64,
from: &str,
to: &str,
form: VvsShortInHrsMinsBeforeCheckSimple,
counter: &State<Arc<()>>,
) -> (Status, String) {
let time = Local::now() + Duration::from_secs_f64(hrs * 60.0 * 60.0);
let form = VvsMinsBeforeCheckSimpleForm {
year: time.year() as _,
month: time.month() as _,
day: time.day() as _,
hour: form.hour.unwrap_or(time.hour() as _),
minute: form.minute.unwrap_or(time.minute() as _),
interchanges: form.interchanges,
minutes: form.minutes,
allowtaxi: form.allowtaxi,
};
vvs_mins_before_check_simple_impl(from, to, form, counter).await
}
#[derive(FromForm)]
struct VvsMinsBeforeSimpleForm {
year: u16,
month: u8,
day: u8,
hour: u8,
minute: u8,
interchanges: Option<u16>,
allowtaxi: Option<bool>,
}
#[get("/vvs/mins_before/simple/<from>/<to>?<form..>")]
async fn vvs_mins_before_simple(
from: &str,
to: &str,
form: VvsMinsBeforeSimpleForm,
counter: &State<Arc<()>>,
) -> (Status, String) {
vvs_mins_before_simple_impl(from, to, form, counter).await
}
async fn vvs_mins_before_simple_impl(
from: &str,
to: &str,
form: VvsMinsBeforeSimpleForm,
counter: &State<Arc<()>>,
) -> (Status, String) {
let _counter = rate_limit(counter).await;
if Arc::strong_count(&counter) > 2 {}
let (_, from) = match vvs::find_stop_best(from).await {
Ok(Some(v)) => v,
Ok(None) => {
return (
Status::NotFound,
format!("err: Couldn't find origin stop {from}"),
)
}
Err(e) => return (Status::InternalServerError, format!("err: {e}")),
};
let (_, to) = match vvs::find_stop_best(to).await {
Ok(Some(v)) => v,
Ok(None) => {
return (
Status::NotFound,
format!("err: Couldn't find destination stop {to}"),
)
}
Err(e) => return (Status::InternalServerError, format!("err: {e}")),
};
match vvs::find_trip_arrival_before(
&from,
&to,
form.year,
form.month,
form.day,
form.hour,
form.minute,
form.interchanges,
form.allowtaxi.is_some_and(|allow| allow),
)
.await
{
Ok(Some(v)) => (Status::Ok, format!("minutes: {v}")),
Ok(None) => (
Status::Gone,
format!("gone: maybe try with different parameters or check vvs.de"),
),
Err(e) => return (Status::InternalServerError, format!("err: {e}")),
}
}
#[derive(FromForm)]
struct VvsMinsBeforeCheckSimpleForm {
year: u16,
month: u8,
day: u8,
hour: u8,
minute: u8,
interchanges: Option<u16>,
minutes: u32,
allowtaxi: Option<bool>,
}
#[get("/vvs/mins_before_check/simple/<from>/<to>?<form..>")]
async fn vvs_mins_before_check_simple(
from: &str,
to: &str,
form: VvsMinsBeforeCheckSimpleForm,
counter: &State<Arc<()>>,
) -> (Status, String) {
vvs_mins_before_check_simple_impl(from, to, form, counter).await
}
async fn vvs_mins_before_check_simple_impl(
from: &str,
to: &str,
form: VvsMinsBeforeCheckSimpleForm,
counter: &State<Arc<()>>,
) -> (Status, String) {
let _counter = rate_limit(counter).await;
let (_, from) = match vvs::find_stop_best(from).await {
Ok(Some(v)) => v,
Ok(None) => {
return (
Status::NotFound,
format!("err: Couldn't find origin stop {from}"),
)
}
Err(e) => return (Status::InternalServerError, format!("err: {e}")),
};
let (_, to) = match vvs::find_stop_best(to).await {
Ok(Some(v)) => v,
Ok(None) => {
return (
Status::NotFound,
format!("err: Couldn't find destination stop {to}"),
)
}
Err(e) => return (Status::InternalServerError, format!("err: {e}")),
};
match vvs::find_trip_arrival_before(
&from,
&to,
form.year,
form.month,
form.day,
form.hour,
form.minute,
form.interchanges,
form.allowtaxi.is_some_and(|allow| allow),
)
.await
{
Ok(Some(v)) => {
if v <= form.minutes {
(Status::Ok, String::new())
} else {
(
Status::Gone,
format!("gone: {v} > {} minutes", form.minutes),
)
}
}
Ok(None) => (
Status::Gone,
format!("gone: maybe try with different parameters or check vvs.de"),
),
Err(e) => return (Status::InternalServerError, format!("err: {e}")),
}
}

366
src/vvs.rs Normal file
View File

@ -0,0 +1,366 @@
use chrono::{DateTime, Local, TimeZone};
use serde::Deserialize;
#[derive(Debug)]
pub struct VvsStopIdentifier {
id: String,
gid: String,
}
pub async fn find_stop_best(query: &str) -> Result<Option<(String, VvsStopIdentifier)>, String> {
Ok(find_stop(query)
.await?
.into_iter()
.filter_map(|(name, _types, identifier)| Some((name, identifier?)))
.next())
}
pub async fn find_stop(
query: &str,
) -> Result<
Vec<(
String,
(Option<String>, Option<String>),
Option<VvsStopIdentifier>,
)>,
String,
> {
let url = format!(
r#"https://www3.vvs.de/mngvvs/XML_STOPFINDER_REQUEST
?SpEncId=0
&coordOutputFormat=EPSG:4326
&serverInfo=1
&suggestApp=vvs
&type_sf=any
&version=10.2.10.139
&jsonp=func
&suggest_macro=vvs
&name_sf={}
"#,
urlencoding::encode(query)
)
.replace(['\r', '\n', ' ', '\t'], "");
match reqwest::get(&url).await {
Ok(response) => match response.text().await {
Ok(response) => {
let response = response
.trim_start_matches("func(")
.trim_end_matches([')', ';']);
#[derive(Deserialize)]
struct Response0a {
#[serde(rename = "stopFinder")]
stop_finder: Response1a,
}
#[derive(Deserialize)]
struct Response0b {
#[serde(rename = "stopFinder")]
stop_finder: Response1b,
}
#[derive(Deserialize)]
struct Response1a {
#[serde(rename = "points")]
points: Vec<Response2>,
}
#[derive(Deserialize)]
struct Response1b {
#[serde(rename = "points")]
points: Response2Container,
}
#[derive(Deserialize)]
struct Response2Container {
#[serde(rename = "point")]
point: Response2,
}
#[derive(Deserialize)]
struct Response2 {
#[serde(rename = "type")]
type1: Option<String>,
#[serde(rename = "anyType")]
type2: Option<String>,
#[serde(rename = "name")]
name: String,
#[serde(rename = "ref")]
identifier: Response3,
}
#[derive(Deserialize)]
struct Response3 {
#[serde(rename = "id")]
id: String,
#[serde(rename = "gid")]
gid: String,
}
match serde_json::from_str::<Response0a>(&response)
.or_else(|ea| serde_json::from_str::<Response0b>(&response)
.map(|response_b| Response0a {
stop_finder: Response1a {
points:vec![response_b.stop_finder.points.point]
}
})
.map_err(|eb| (ea, eb))
)
{
Ok(response) => Ok(response
.stop_finder
.points
.into_iter()
.map(|point| {
(
point.name,
(point.type1, point.type2),
if !(point.identifier.id.is_empty() || point.identifier.gid.is_empty() || point.identifier.id == "-1" || point.identifier.gid == "-1") {
Some(VvsStopIdentifier {
id: point.identifier.id,
gid: point.identifier.gid,
})
} else { None }
)
})
.collect()),
Err((ea, eb)) => {
Err(format!("Couldn't parse HTTP response from URL {url:?}: {ea} & {eb}\nResponse was (raw):\n{response}"))?
}
}
}
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}"))?,
}
}
/// requests trips arriving before or at the specified time.
/// of the trips the api returned, ignores all that have more than `max_interchanges` interchanges, if `max_interchanges.is_some()`.
/// of the remaining trips, calculates how many minutes before the requested arrival time the departure time is, and returns the minimum of these durations in minutes.
/// For example, if you request to arrive at 8:50, a trip arrives at 8:45 and departs at 8:40, then this function would return `Some(10)`.
/// Returns `None` if no valid trips were found.
pub async fn find_trip_arrival_before(
from: &VvsStopIdentifier,
to: &VvsStopIdentifier,
year: u16,
month: u8,
day: u8,
hour: u8,
minute: u8,
max_interchanges: Option<u16>,
allow_taxi: bool,
) -> Result<Option<u32>, String> {
if year > 9999 {
Err(format!(
"Year {year} is out of range (cannot be represented with length 4)"
))?;
}
if month > 99 {
Err(format!(
"Month {month} is out of range (cannot be represented with length 2)"
))?;
}
if day > 99 {
Err(format!(
"Day {day} is out of range (cannot be represented with length 2)"
))?;
}
if hour > 99 {
Err(format!(
"Hour {hour} is out of range (cannot be represented with length 2)"
))?;
}
if minute > 99 {
Err(format!(
"Minute {minute} is out of range (cannot be represented with length 2)"
))?;
}
let url = format!(
r#"
https://www3.vvs.de/mngvvs/XML_TRIP_REQUEST2
?SpEncId=0
&changeSpeed=normal
&computationType=sequence
&coordOutputFormat=EPSG:4326
&cycleSpeed=14
&deleteAssignedStops=0
&deleteITPTWalk=0
&descWithElev=1
&illumTransfer=on
&itOptionsActive=1
&itdDate={year:0>4}{month:0>2}{day:0>2}
&itdTime={hour:0>2}{minute:0>2}
&itdTripDateTimeDepArr=arr
&language=de
&locationServerActive=1
&macroWebTrip=true
&name_destination={}
&name_origin={}
&noElevationProfile=1
&noElevationSummary=1
&outputFormat=rapidJSON
&outputOptionsActive=1
&ptOptionsActive=1
&routeType=leasttime
&searchLimitMinutes=360
&securityOptionsActive=1
&serverInfo=1
&trITArrMOT=100
&trITArrMOTvalue=15
&trITDepMOT=100
&trITDepMOTvalue=15
&tryToFindLocalityStops=1
&type_destination=any
&type_origin=any
&useElevationData=1
&useLocalityMainStop=0
&useRealtime=1
&useUT=1
&version=10.2.10.139
&w_objPrefAl=12
&w_regPrefAm=1
"#,
urlencoding::encode(&to.gid),
urlencoding::encode(&from.gid)
)
.replace(['\r', '\n', ' ', '\t'], "");
match reqwest::get(&url).await {
Ok(response) => match response.text().await {
Ok(response) => {
#[derive(Deserialize)]
struct Response0 {
#[serde(rename = "journeys")]
journeys: Vec<Response1>,
}
#[derive(Deserialize)]
struct Response1 {
#[serde(rename = "interchanges")]
interchanges: i32,
#[serde(rename = "legs")]
legs: Vec<Response2>,
}
#[derive(Deserialize)]
struct Response2 {
/// in seconds
#[serde(rename = "duration")]
duration: i32,
#[serde(rename = "origin")]
origin: Response3,
#[serde(rename = "destination")]
destination: Response4,
#[serde(rename = "transportation")]
transportation: Response5,
}
#[derive(Deserialize)]
struct Response3 {
#[serde(rename = "name")]
name: String,
#[serde(rename = "departureTimeBaseTimetable")]
pub departure_time_base_timetable: Option<DateTime<Local>>,
#[serde(rename = "departureTimePlanned")]
pub departure_time_planned: Option<DateTime<Local>>,
#[serde(rename = "departureTimeEstimated")]
pub departure_time_estimated: Option<DateTime<Local>>,
}
#[derive(Deserialize)]
struct Response4 {
#[serde(rename = "name")]
name: String,
#[serde(rename = "arrivalTimeBaseTimetable")]
pub arrival_time_base_timetable: Option<DateTime<Local>>,
#[serde(rename = "arrivalTimePlanned")]
pub arrival_time_planned: Option<DateTime<Local>>,
#[serde(rename = "arrivalTimeEstimated")]
pub arrival_time_estimated: Option<DateTime<Local>>,
}
#[derive(Deserialize)]
struct Response5 {
#[serde(rename = "name")]
name: String,
#[serde(rename = "number")]
number: String,
}
match serde_json::from_str::<Response0>(&response) {
Ok(response) => Ok(response
.journeys
.iter()
.rev()
.filter_map(|journey| {
if let (Some(first_leg), Some(last_leg)) =
(journey.legs.first(), journey.legs.last())
{
if !allow_taxi && journey.legs.iter().any(|leg| leg.transportation.name.to_lowercase().contains("taxi") || leg.transportation.number.to_lowercase().contains("taxi")) {
return None;
}
if max_interchanges
.is_none_or(|max| journey.interchanges <= max as i32)
{
if let Some(arrival_time) = last_leg
.destination
.arrival_time_estimated
.as_ref()
.or(last_leg.destination.arrival_time_planned.as_ref())
.or(last_leg
.destination
.arrival_time_base_timetable
.as_ref())
{
if *arrival_time
<= Local
.with_ymd_and_hms(
year as _,
month as _,
day as _,
hour as _,
minute as _,
59,
)
.latest()?
{
if let Some(departure_time) = first_leg
.origin
.departure_time_estimated
.as_ref()
.or(last_leg.origin.departure_time_planned.as_ref())
.or(last_leg
.origin
.departure_time_base_timetable
.as_ref())
{
let arrival_wanted = Local
.with_ymd_and_hms(
year as _,
month as _,
day as _,
hour as _,
minute as _,
0,
)
.latest()?;
if *departure_time <= arrival_wanted {
Some(
(arrival_wanted - departure_time)
.num_minutes()
as _,
)
} else {
None
}
} else {
None
}
} else {
None
}
} else {
None
}
} else {
None
}
} else {
None
}
})
.min()),
Err(e) => Err(format!("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 make GET request to URL {url:?}: {e}"))?,
}
}