init
This commit is contained in:
commit
b56a256682
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/target
|
2608
Cargo.lock
generated
Normal file
2608
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
Normal file
15
Cargo.toml
Normal 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
74
src/api.html
Normal 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/<stop></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: <actual name of the stop></code> or <code>err: ...</code>.
|
||||||
|
<h2><code>./vvs/mins_before[_check]/simple/<from>/<to>?year=&month=&day=&hour=&minute=&interchanges=&minutes=</code></h2>
|
||||||
|
<p>
|
||||||
|
Searches for connections from <code><from></code> to <code><to></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><from></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><from></code> and <code><to></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: <minutes as an integer></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: <a> > <b> 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%&month=%MONTH%&day=%DAY%&hour=8&minute=40&interchanges=0&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%&month=%MONTH%&day=%DAY%&hour=8&minute=40&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&interchanges=0&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&interchanges=0&minutes=25' || echo whoops, bus gone</code>
|
||||||
|
</body></html>
|
63
src/bahnhof.rs
Normal file
63
src/bahnhof.rs
Normal 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
101
src/index.html
Normal 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
461
src/main.rs
Normal 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
366
src/vvs.rs
Normal 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
|
||||||
|
¯oWebTrip=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}"))?,
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user