Cleaned up project structure
Some checks failed
Rust / build_and_test (push) Failing after 1m16s

This commit is contained in:
Lukas Wölfer
2026-01-17 21:58:29 +01:00
parent 7baff3a50c
commit 681cc0f59d
17 changed files with 253 additions and 162 deletions

48
src/fetching/caching.rs Normal file
View File

@@ -0,0 +1,48 @@
use std::{collections::HashMap, path::Path};
use reqwest::{Client, ClientBuilder};
use crate::{dance_info::DanceInfo, worldsdc::DanceInfoError};
use super::DanceInfoParser;
struct CachingFetcher {
hitcache: Vec<(u32, String)>,
errorcache: Vec<(u32, String)>,
client: Client,
}
#[derive(thiserror::Error, Debug)]
enum CachingFetcherCreationError {
#[error("Could not create client: {0}")]
ClientError(#[from] reqwest::Error),
}
impl CachingFetcher {
pub fn new(cachepath: &Path) -> Result<Self, CachingFetcherCreationError> {
let client = ClientBuilder::new().build()?;
Ok(Self {
hitcache: vec![],
errorcache: vec![],
client,
})
}
pub async fn fetch(&mut self, id: u32) -> Result<DanceInfo, DanceInfoError> {
let mut params = HashMap::new();
params.insert("q", id.to_string());
let response = self
.client
.request(
reqwest::Method::POST,
"https://points.worldsdc.com/lookup2020/find",
)
.form(&params)
.send()
.await
.map_err(DanceInfoError::Request)?;
let x: DanceInfoParser = response.json().await.map_err(DanceInfoError::JsonParse)?;
Ok(x.into())
}
}

39
src/fetching/mod.rs Normal file
View File

@@ -0,0 +1,39 @@
use crate::dance_info::DanceInfo;
use crate::fetching::types::DanceInfoError;
mod scoringdance;
mod worldsdc;
pub mod types;
use async_trait::async_trait;
use std::sync::Arc;
#[async_trait]
pub trait WsdcFetcher: Send + Sync {
async fn fetch(&self, id: u32) -> Result<DanceInfo, DanceInfoError>;
}
pub struct WorldsdcFetcher;
pub struct ScoringDanceFetcher;
#[async_trait]
impl WsdcFetcher for WorldsdcFetcher {
async fn fetch(&self, id: u32) -> Result<DanceInfo, DanceInfoError> {
worldsdc::fetch_wsdc_info_wsdc(id).await
}
}
#[async_trait]
impl WsdcFetcher for ScoringDanceFetcher {
async fn fetch(&self, id: u32) -> Result<DanceInfo, DanceInfoError> {
scoringdance::fetch_wsdc_info_scoring_dance(id).await
}
}
/// Convenience alias for a shared, dynamic fetcher
pub type DynWsdcFetcher = Arc<dyn WsdcFetcher>;
/// Back-compat helper that uses the `WorldsdcFetcher`.
pub async fn fetch_wsdc_info(id: u32) -> Result<DanceInfo, DanceInfoError> {
WorldsdcFetcher.fetch(id).await
}

View File

@@ -0,0 +1,203 @@
use std::str::FromStr;
use reqwest::ClientBuilder;
use scraper::{ElementRef, Html, Selector};
use crate::{
app_signature,
dance_info::{CompState, DanceInfo, DanceRank, DanceRole},
fetching::DanceInfoError,
};
#[derive(thiserror::Error, Debug)]
pub enum ScoringParseError {
#[error("Could not parse: {0}")]
ParseMismatch(String),
}
fn parse_card(t: ElementRef) -> Result<(String, Vec<Vec<String>>), ScoringParseError> {
#[allow(clippy::unwrap_used)]
let title_selector = Selector::parse("div.card-header").unwrap();
#[allow(clippy::unwrap_used)]
let table_selector = Selector::parse("div.card-body > table").unwrap();
#[allow(clippy::unwrap_used)]
let row_selector = Selector::parse("tr").unwrap();
#[allow(clippy::unwrap_used)]
let cell_selector = Selector::parse("th,td").unwrap();
let table = t
.select(&table_selector)
.next()
.ok_or_else(|| ScoringParseError::ParseMismatch("table".to_owned()))?;
let title = t
.select(&title_selector)
.next()
.ok_or_else(|| ScoringParseError::ParseMismatch("title".to_owned()))?
.text()
.collect::<Vec<_>>()
.join("")
.trim()
.to_owned();
let parsed_table = table
.select(&row_selector)
.map(|row| {
row.select(&cell_selector)
.map(|v| v.text().collect::<String>().trim().to_string())
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
Ok((title, parsed_table))
}
fn parse_details(d: &[Vec<String>]) -> Result<(String, String), ScoringParseError> {
let first_name_row = d.iter().find(|v| {
v.first()
.is_some_and(|v| v.to_lowercase().contains("first name"))
});
let last_name_row = d.iter().find(|v| {
v.first()
.is_some_and(|v| v.to_lowercase().contains("last name"))
});
let first_name = first_name_row
.ok_or_else(|| ScoringParseError::ParseMismatch("first_name".to_owned()))?
.last()
.ok_or_else(|| ScoringParseError::ParseMismatch("first_name value".to_owned()))?;
let last_name = last_name_row
.ok_or_else(|| ScoringParseError::ParseMismatch("last_name".to_owned()))?
.last()
.ok_or_else(|| ScoringParseError::ParseMismatch("first_name value".to_owned()))?;
Ok((first_name.clone(), last_name.clone()))
}
fn parse_stats(
d: &[Vec<String>],
) -> Result<(DanceRole, CompState, Option<CompState>), ScoringParseError> {
let chapters = d.chunk_by(|_, b| b.len() != 1).map(|v| {
let (a, b) = v.split_first().unwrap();
let a = a.first().unwrap();
let b = b
.iter()
.map(|v| [v.first().unwrap(), v.last().unwrap()])
.collect::<Vec<_>>();
(a, b)
});
let (all_time, rest) = chapters.partition::<Vec<_>, _>(|(a, _)| a.to_lowercase() == "all time");
let all_time = &all_time.split_first().unwrap().0.1;
let mut sorted_chapters = rest
.into_iter()
.map(|(chapter, items)| {
let rank: DanceRank = serde_plain::from_str(chapter).map_err(|_| chapter.to_owned())?;
Ok::<(DanceRank, Vec<[&String; 2]>), String>((rank, items))
})
.filter_map(|v| match v {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!("Unknown chapter in parsed html: {e}");
None
}
})
.collect::<Vec<_>>();
sorted_chapters.sort_by_key(|(a, _)| *a);
sorted_chapters.reverse();
let leader_rank = sorted_chapters.iter().find_map(|(rank, items)| {
items
.iter()
.find(|[a, _]| a.to_lowercase().contains("points leader"))
.map(|[_, points]| (rank, points))
});
let follower_rank = sorted_chapters.iter().find_map(|(rank, items)| {
items
.iter()
.find(|[a, _]| a.to_lowercase().contains("points follower"))
.map(|[_, points]| (rank, points))
});
let primary_role: DanceRole = all_time
.iter()
.find_map(|[key, value]| {
if !key.to_lowercase().contains("primary role") {
return None;
}
Some(value)
})
.map(|arg0: &&std::string::String| DanceRole::from_str(arg0.as_str()).unwrap())
.unwrap();
let ((rank, points), non_d) = match primary_role {
DanceRole::Leader => (leader_rank.unwrap(), follower_rank),
DanceRole::Follower => (follower_rank.unwrap(), leader_rank),
};
let dominant_comp = CompState {
points: points.parse().unwrap(),
rank: *rank,
};
let non_dominant_comp = non_d.map(|(rank, points)| CompState {
points: points.parse().unwrap(),
rank: *rank,
});
Ok((primary_role, dominant_comp, non_dominant_comp))
// dbg!(chapters.collect::<Vec<_>>());
}
fn extract_tables(html: &str) -> Result<Vec<(String, Vec<Vec<String>>)>, ScoringParseError> {
dbg!(&html);
let document = Html::parse_document(html);
let card_selector = Selector::parse("div:has( > div.card-header)").unwrap();
document
.select(&card_selector)
.inspect(|v| {
dbg!(&v);
})
.map(parse_card)
.collect()
}
fn parse_info(html: &str) -> Result<DanceInfo, ScoringParseError> {
let tables = extract_tables(html)?;
dbg!(&tables);
let details = &tables
.iter()
.find(|(v, _)| v.to_lowercase().contains("detail"))
.ok_or_else(|| ScoringParseError::ParseMismatch("detail card".to_owned()))?
.1;
let stats = &tables
.iter()
.find(|(v, _)| v.to_lowercase().contains("stats"))
.ok_or_else(|| ScoringParseError::ParseMismatch("stats card".to_owned()))?
.1;
let (dominant_role, dominant_role_comp, non_dominant_role_comp) = parse_stats(stats)?;
let (firstname, lastname) = parse_details(details)?;
Ok(DanceInfo {
firstname,
lastname,
dominant_role,
dominant_role_comp,
non_dominant_role_comp,
})
}
#[test]
fn test_parse_table() {
dbg!(parse_info(include_str!("../../test_data/2026-01-07_robert.html")));
}
pub async fn fetch_wsdc_info_scoring_dance(id: u32) -> Result<DanceInfo, DanceInfoError> {
let client = ClientBuilder::new()
.user_agent(app_signature())
.build()
.map_err(DanceInfoError::ClientBuild)?;
let url = format!("https://scoring.dance/enUS/wsdc/registry/{id}.html");
let request = client
.request(reqwest::Method::GET, url)
.build()
.map_err(DanceInfoError::RequestBuild)?;
let response = client
.execute(request)
.await
.map_err(DanceInfoError::Request)?;
parse_info(response.text().await.unwrap().as_str()).map_err(DanceInfoError::HtmlParse)
}

67
src/fetching/types.rs Normal file
View File

@@ -0,0 +1,67 @@
use crate::dance_info::{CompState, DanceInfo, DanceRank, DanceRole};
#[derive(thiserror::Error, Debug)]
pub enum DanceInfoError {
#[error("Failed to build client: {0}")]
ClientBuild(reqwest::Error),
#[error("Failed to build request: {0}")]
RequestBuild(reqwest::Error),
#[error("Request error: {0:#?}")]
Request(reqwest::Error),
#[error("Failed to parse response: {0}")]
JsonParse(reqwest::Error),
#[error("Failed to parse html: {0}")]
HtmlParse(#[from] super::scoringdance::ScoringParseError),
}
#[derive(serde::Deserialize, Debug)]
enum OptionalDanceRank {
#[serde(rename = "N/A")]
NotAvailable,
#[serde(untagged)]
Rank(DanceRank),
}
#[derive(serde::Deserialize, Debug)]
enum OptionalDancePoints {
#[serde(rename = "N/A")]
NotAvailable,
#[serde(untagged)]
Points(u16),
}
#[derive(serde::Deserialize, Debug)]
pub struct DanceInfoParser {
pub dancer_first: String,
pub dancer_last: String,
pub short_dominate_role: DanceRole,
#[allow(dead_code)]
pub short_non_dominate_role: DanceRole,
pub dominate_role_highest_level_points: u16,
pub dominate_role_highest_level: DanceRank,
pub non_dominate_role_highest_level_points: OptionalDancePoints,
pub non_dominate_role_highest_level: OptionalDanceRank,
}
impl From<DanceInfoParser> for DanceInfo {
fn from(value: DanceInfoParser) -> Self {
let non_dominant_role_comp = if let OptionalDanceRank::Rank(r) =
value.non_dominate_role_highest_level
&& let OptionalDancePoints::Points(l) = value.non_dominate_role_highest_level_points
{
Some(CompState { rank: r, points: l })
} else {
None
};
Self {
firstname: value.dancer_first,
lastname: value.dancer_last,
dominant_role: value.short_dominate_role,
dominant_role_comp: CompState {
rank: value.dominate_role_highest_level,
points: value.dominate_role_highest_level_points,
},
non_dominant_role_comp,
}
}
}

61
src/fetching/worldsdc.rs Normal file
View File

@@ -0,0 +1,61 @@
use std::collections::HashMap;
use crate::{
app_signature,
dance_info::{CompState, DanceInfo, DanceRank, DanceRole},
fetching::{DanceInfoError, types::DanceInfoParser, scoringdance::{self, fetch_wsdc_info_scoring_dance}},
};
use reqwest::ClientBuilder;
pub async fn fetch_wsdc_info_wsdc(id: u32) -> Result<DanceInfo, DanceInfoError> {
let client = ClientBuilder::new()
.user_agent(app_signature())
.build()
.map_err(DanceInfoError::ClientBuild)?;
let mut params = HashMap::new();
let url = if cfg!(test) {
// "https://o5grQU3Y.free.beeceptor.com/lookup2020/find"
"http://localhost:8000"
} else {
"https://points.worldsdc.com/lookup2020/find"
};
params.insert("num", id.to_string());
let request = client
.request(reqwest::Method::POST, url)
.form(&params)
.build()
.map_err(DanceInfoError::RequestBuild)?;
let response = client
.execute(request)
.await
.map_err(DanceInfoError::Request)?;
let x: DanceInfoParser = response.json().await.map_err(DanceInfoError::JsonParse)?;
Ok(x.into())
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, reason = "Allow unwrap in tests")]
use crate::fetching::fetch_wsdc_info;
#[test]
#[ignore = "Only run when the mock api is setup"]
fn test_fetch_wsdc() {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(o) => o,
Err(e) => {
tracing::error!("Could not start runtime: {e}");
return;
}
};
let x = rt.block_on(fetch_wsdc_info(7));
dbg!(&x);
x.unwrap();
}
}