Compare commits
Generated
+464
-525
File diff suppressed because it is too large
Load Diff
+15
-4
@@ -1,14 +1,25 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "departure"
|
name = "departure"
|
||||||
version = "0.1.0"
|
version = "0.2.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
scraper = { version = "*", default-features = false }
|
anyhow = "1.0.68"
|
||||||
anyhow = { version = "*", default-features = false }
|
chrono = { version = "0.4.23", default-features = false, features = ["clock"] }
|
||||||
ureq = { version = "*", default-features = false , features = ["tls"]}
|
ureq = { version = "2.5.0", default-features = false, features = ["tls"] }
|
||||||
|
|
||||||
|
serde = { version = "1.0.151", features = ["serde_derive"], default-features = false }
|
||||||
|
serde_derive = "1.0.151"
|
||||||
|
serde_json = "1.0.91"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
lto = true
|
lto = true
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
criterion = "0.4.0"
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "benchmark"
|
||||||
|
harness = false
|
||||||
|
|||||||
@@ -1,2 +1,7 @@
|
|||||||
# departure
|
# departure
|
||||||
|
|
||||||
|
DVB Abfahrtsmonitor
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|
https://asciinema.org/a/cprkgCrl3PrFwhI3vSm4Nw1TJ
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||||
|
|
||||||
|
use departure::response::Response;
|
||||||
|
|
||||||
|
use std::fs::read_to_string;
|
||||||
|
|
||||||
|
fn load_json() -> String {
|
||||||
|
read_to_string("benches/reply.json").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn criterion_benchmark(c: &mut Criterion) {
|
||||||
|
let content = load_json();
|
||||||
|
c.bench_function("parse JSON", |b| {
|
||||||
|
b.iter(|| serde_json::from_str::<'_, Response>(black_box(&content)))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
criterion_group!(benches, criterion_benchmark);
|
||||||
|
criterion_main!(benches);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"Name":"Forschungszentrum","Status":{"Code":"Ok"},"Place":"Rossendorf (Dresden)","ExpirationTime":"\/Date(1672832857580+0100)\/","Departures":[{"Id":"voe:27753: :R:j23","DlId":"de:vvo:27-753","LineName":"753","Direction":"Radeberg Bahnhof","Platform":{"Name":"1","Type":"Platform"},"Mot":"IntercityBus","RealTime":"\/Date(1672832760000-0000)\/","ScheduledTime":"\/Date(1672832400000-0000)\/","State":"Delayed","RouteChanges":[],"Diva":{"Number":"27753","Network":"voe"},"CancelReasons":[]},{"Id":"voe:15261:b:R:j23","DlId":"de:vvo:15-261-b","LineName":"261","Direction":"Sebnitz Busbahnhof","Platform":{"Name":"2","Type":"Platform"},"Mot":"PlusBus","RealTime":"\/Date(1672833120000-0000)\/","ScheduledTime":"\/Date(1672833060000-0000)\/","State":"Delayed","RouteChanges":[],"Diva":{"Number":"15261b","Network":"voe"},"CancelReasons":[]}]}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
use std::env::args;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
pub struct Args {
|
||||||
|
pub name: String,
|
||||||
|
pub number_stops: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Args {
|
||||||
|
pub fn try_get() -> Result<Self> {
|
||||||
|
let mut iter = args().into_iter().skip(1);
|
||||||
|
let name = iter.next().context("getting station name")?;
|
||||||
|
let number_stops = get_number_stops(iter.next()).context("getting number of stops")?;
|
||||||
|
|
||||||
|
Ok(Self { name, number_stops })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_number_stops(arg: Option<String>) -> Result<usize> {
|
||||||
|
match arg {
|
||||||
|
Some(s) => s.parse().context("parsing number"),
|
||||||
|
None => Ok(5),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
use anyhow::{bail, Context, Result};
|
|
||||||
use scraper::{ElementRef, Html, Selector};
|
|
||||||
|
|
||||||
pub(crate) struct Extractor {
|
|
||||||
time: Selector,
|
|
||||||
departure: Selector,
|
|
||||||
delay: Selector,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Extractor {
|
|
||||||
pub(crate) fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
time: Self::get_time_selector(),
|
|
||||||
departure: Self::get_departure_selector(),
|
|
||||||
delay: Self::get_delay_selector(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_time_selector() -> Selector {
|
|
||||||
Selector::parse("div.col.c2of12.tour p").unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_departure_selector() -> Selector {
|
|
||||||
Selector::parse("li[data-filter][data-departure-id][data-departure-time]").unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_delay_selector() -> Selector {
|
|
||||||
Selector::parse("span.late").unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn get_departures<'a>(&'a self, html: &'a Html) -> impl Iterator<Item = ElementRef> {
|
|
||||||
// `Select` does not implement DoubleEndedIterator, so we need to collect it first
|
|
||||||
let mut result = html.select(&self.departure).collect::<Vec<_>>();
|
|
||||||
result.reverse();
|
|
||||||
// we seem to extract the blocks twice with slightly different layout, so we discard half of them
|
|
||||||
result.truncate(result.len() / 2);
|
|
||||||
result.into_iter()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn get_transport_line(departure_block: scraper::ElementRef) -> Result<&str> {
|
|
||||||
let val = departure_block.value();
|
|
||||||
let result = val
|
|
||||||
.attr("data-filter")
|
|
||||||
.context("accessing 'data-filter'")?
|
|
||||||
.trim();
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn extract_delay(
|
|
||||||
departure_block: scraper::ElementRef,
|
|
||||||
late_selector: &Selector,
|
|
||||||
) -> Option<String> {
|
|
||||||
departure_block
|
|
||||||
.select(late_selector)
|
|
||||||
.next()
|
|
||||||
.map(|content| content.inner_html())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn get_times(&self, departure: scraper::ElementRef) -> Result<String> {
|
|
||||||
let delay = Self::extract_delay(departure, &self.delay);
|
|
||||||
for time_block in departure
|
|
||||||
.select(&self.time)
|
|
||||||
.next()
|
|
||||||
.context("extracting time block")?
|
|
||||||
.children()
|
|
||||||
{
|
|
||||||
let value = time_block.value();
|
|
||||||
|
|
||||||
if value.is_text() {
|
|
||||||
let scheduled_time = value
|
|
||||||
.as_text()
|
|
||||||
.context("extracting text of scheduled time")?
|
|
||||||
.trim();
|
|
||||||
if !scheduled_time.is_empty() {
|
|
||||||
let mut result = "Abfahrt: ".to_owned();
|
|
||||||
result.push_str(scheduled_time);
|
|
||||||
|
|
||||||
if let Some(late_text) = &delay {
|
|
||||||
result.push_str(" | Verspätet: ");
|
|
||||||
result.push_str(late_text);
|
|
||||||
}
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bail!("could not get the times")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
mod args;
|
||||||
|
mod query;
|
||||||
|
pub mod response;
|
||||||
|
|
||||||
|
pub use args::Args;
|
||||||
|
pub use query::get_departures_for_station;
|
||||||
|
pub use response::format_departures;
|
||||||
+6
-16
@@ -1,21 +1,11 @@
|
|||||||
mod extractor;
|
use anyhow::{Context, Result};
|
||||||
mod query;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use departure::{format_departures, get_departures_for_station, Args};
|
||||||
use scraper::Html;
|
|
||||||
|
|
||||||
use extractor::Extractor;
|
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let message = query::query_server()?;
|
let args = Args::try_get().context("parsing args")?;
|
||||||
let html = Html::parse_document(&message);
|
let response = get_departures_for_station(args).context("getting departures")?;
|
||||||
|
let formatted = format_departures(response);
|
||||||
let extractor = Extractor::new();
|
println!("{formatted}");
|
||||||
|
|
||||||
for departure in extractor.get_departures(&html) {
|
|
||||||
println!("{}", Extractor::get_transport_line(departure)?);
|
|
||||||
println!("{}", extractor.get_times(departure)?);
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-7
@@ -1,11 +1,91 @@
|
|||||||
use anyhow::{anyhow, Result};
|
use std::collections::HashMap;
|
||||||
|
|
||||||
pub(crate) const SIEDLUNG: &str =
|
use anyhow::{bail, Context, Result};
|
||||||
"https://www.vvo-online.de/de/fahrplan/aktuelle-abfahrten-ankuenfte/abfahrten?stopid=33006765";
|
|
||||||
|
|
||||||
pub(crate) fn query_server() -> Result<String> {
|
use crate::{
|
||||||
match ureq::get(SIEDLUNG).call() {
|
response::{PointFinderResponse, Response},
|
||||||
Ok(response) => Ok(response.into_string().map_err(|e| anyhow!(e))?),
|
Args,
|
||||||
Err(e) => Err(anyhow!(e)),
|
};
|
||||||
|
|
||||||
|
const DEPARTURE_MONITOR: &str = "https://webapi.vvo-online.de/dm";
|
||||||
|
const POINT_FINDER: &str = "https://webapi.vvo-online.de/tr/pointfinder";
|
||||||
|
|
||||||
|
pub fn get_departures_for_station(args: Args) -> Result<Response> {
|
||||||
|
let stopid = match get_stopid_from_station_name(args.name) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(e) => bail!(
|
||||||
|
"could not resolve stop ID. Check the spelling of the stop name\nserde error: {e}"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let number_stops = args.number_stops.to_string();
|
||||||
|
let kv_pair = get_key_value_pair_for_departure(&stopid, &number_stops);
|
||||||
|
|
||||||
|
let json_string = create_json_request(DEPARTURE_MONITOR, kv_pair)
|
||||||
|
.context("creating JSON request")?
|
||||||
|
.into_string()
|
||||||
|
.context("converting JSON reply to string")?;
|
||||||
|
|
||||||
|
let result = serde_json::from_str(&json_string)?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_stopid_from_station_name(station_name: String) -> Result<String> {
|
||||||
|
let kv_pair = get_key_value_pair_for_stopid(&station_name);
|
||||||
|
|
||||||
|
let json_string = create_json_request(POINT_FINDER, kv_pair)
|
||||||
|
.context("creating JSON request")?
|
||||||
|
.into_string()
|
||||||
|
.context("converting JSON reply to string")?;
|
||||||
|
|
||||||
|
let response: PointFinderResponse = serde_json::from_str(&json_string)?;
|
||||||
|
print_station_name(&response);
|
||||||
|
|
||||||
|
let result = response.point.stopid;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_station_name(response: &PointFinderResponse) {
|
||||||
|
print!("{}", response.point.name);
|
||||||
|
let area = &response.point.area;
|
||||||
|
if !area.is_empty() {
|
||||||
|
print!(", {area}");
|
||||||
}
|
}
|
||||||
|
println!("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_json_request(url: &str, kv_pair: HashMap<&str, &str>) -> Result<ureq::Response> {
|
||||||
|
ureq::post(url)
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
|
.set("charset", "UTF-8")
|
||||||
|
.send_string(&create_json_body(kv_pair))
|
||||||
|
.context("sending JSON to server")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_json_body(kv_pair: HashMap<&str, &str>) -> String {
|
||||||
|
let mut result = String::from("{");
|
||||||
|
for (k, v) in kv_pair {
|
||||||
|
result.push_str(&format!("\"{k}\":\"{v}\","))
|
||||||
|
}
|
||||||
|
result.push('}');
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_key_value_pair_for_departure<'a>(
|
||||||
|
stopid: &'a str,
|
||||||
|
number_stops: &'a str,
|
||||||
|
) -> HashMap<&'a str, &'a str> {
|
||||||
|
let mut result = HashMap::new();
|
||||||
|
result.insert("stopid", stopid);
|
||||||
|
result.insert("limit", number_stops);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_key_value_pair_for_stopid(name: &str) -> HashMap<&str, &str> {
|
||||||
|
let mut result = HashMap::new();
|
||||||
|
result.insert("query", name);
|
||||||
|
result.insert("limit", "1");
|
||||||
|
result.insert("regionalOnly", "true");
|
||||||
|
result.insert("stopsOnly", "true");
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
use std::fmt::{Debug, Display};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use chrono::{DateTime, Local, TimeZone, Utc};
|
||||||
|
use serde::{de, Deserialize, Deserializer};
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
pub struct Departure {
|
||||||
|
line_name: String,
|
||||||
|
direction: String,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_realtime")]
|
||||||
|
real_time: Option<DateTime<Local>>,
|
||||||
|
#[serde(deserialize_with = "deserialize_timestamp")]
|
||||||
|
scheduled_time: DateTime<Local>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
pub struct Response {
|
||||||
|
pub departures: Vec<Departure>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize a timestamp in the form of '/Date(1671713820000-0000)/'
|
||||||
|
fn deserialize_timestamp<'de, D>(deserializer: D) -> Result<DateTime<Local>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
let timestamp: i64 = s
|
||||||
|
.trim_start_matches("/Date(")
|
||||||
|
.trim_end_matches(")/")
|
||||||
|
.split('-')
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| de::Error::custom("Invalid timestamp format"))?
|
||||||
|
.parse()
|
||||||
|
.map_err(de::Error::custom)?;
|
||||||
|
let result = Utc.timestamp_millis_opt(timestamp).unwrap();
|
||||||
|
|
||||||
|
let local_tz = chrono::offset::Local::now().timezone();
|
||||||
|
Ok(result.with_timezone(&local_tz))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_realtime<'de, D>(deserializer: D) -> Result<Option<DateTime<Local>>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
match deserialize_timestamp(deserializer) {
|
||||||
|
Ok(inner) => Ok(Some(inner)),
|
||||||
|
Err(_) => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Departure {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&format!("{} {}\n", &self.line_name, &self.direction))?;
|
||||||
|
|
||||||
|
let scheduled_time = self.scheduled_time.format("%H:%M");
|
||||||
|
f.write_str(&format!("departure: {}", &scheduled_time))?;
|
||||||
|
|
||||||
|
if let Some(real_time) = self.real_time {
|
||||||
|
let actual_time = real_time.format("%H:%M");
|
||||||
|
if real_time != self.scheduled_time {
|
||||||
|
f.write_str(&format!(" | delayed: {actual_time} "))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_departures(response: Response) -> String {
|
||||||
|
let departures: Vec<String> = response.departures.iter().map(|d| d.to_string()).collect();
|
||||||
|
departures.join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
pub struct PointFinderResponse {
|
||||||
|
#[serde(deserialize_with = "deserialize_points", rename = "Points")]
|
||||||
|
pub point: Point,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
pub struct Point {
|
||||||
|
pub stopid: String,
|
||||||
|
pub name: String,
|
||||||
|
pub area: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<(&str, &str, &str)> for Point {
|
||||||
|
fn from(value: (&str, &str, &str)) -> Self {
|
||||||
|
Self {
|
||||||
|
stopid: value.0.into(),
|
||||||
|
name: value.1.into(),
|
||||||
|
area: value.2.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize a Point Response in the form of
|
||||||
|
/// '\"33000742|||Helmholtzstraße|5655904|4621157|0||\"'
|
||||||
|
fn extract_data_from_response(line: &str) -> Result<Point> {
|
||||||
|
let mut iter = line.trim_start_matches("\\\"").split_terminator('|');
|
||||||
|
|
||||||
|
let stopid = iter.next().context("getting stopid from line")?;
|
||||||
|
let area = iter.nth(1).context("getting area from line")?;
|
||||||
|
let name = iter.next().context("getting stop name from line")?;
|
||||||
|
Ok((stopid, name, area).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_points<'de, D>(deserializer: D) -> Result<Point, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let stops: Vec<String> = Vec::deserialize(deserializer)?;
|
||||||
|
let first_stop_line = stops
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| de::Error::custom("could not get first stop"))?;
|
||||||
|
let point = extract_data_from_response(first_stop_line).map_err(de::Error::custom)?;
|
||||||
|
Ok(point)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user