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