diff --git a/schemas/Character.md b/schemas/Character.md new file mode 100644 index 0000000..6244ec1 --- /dev/null +++ b/schemas/Character.md @@ -0,0 +1,104 @@ +# Character + +|Key|Value|Description| +|---|---|---| +|`id`|`u64`|The character's ID on the Lodestone.| +|`name`|`String`|The character's name.| +|`world`|`String`|The world the character is on.| +|`race`|`Race`|The character's race. See Race section below.| +|`clan`|`Clan`|The character's clan. See Clan section below.| +|`gender`|`Gender`|The character's gender. See Gender section below.| +|`title`|`String?`|The character's title.| +|`name_day`|`String`|The character's birth date, in the form `"18th Sun of the 2nd Umbral Moon"`.| +|`guardian`|`Guardian`|The character's guardian deity. See Guardian section below.| +|`city_state`|`CityState`|The character's starting city-state. See CityState section below.| +|`grand_company`|`GrandCompanyInfo?`|The character's Grand Company affiliation and rank. See GrandCompanyInfo section below.| +|`free_company_id`|`u64?`|The ID of the character's Free Company, if any.| +|`profile_text`|`String`|The profile text for this character on the Lodestone. If empty, this will be the empty string (`""`).| + +## Race + +An enumerated type represented as a string. + +|Race|String| +|---|---| +|Au Ra|`"AuRa"`| +|Elezen|`"Elezen"`| +|Hyur|`"Hyur"`| +|Lalafell|`"Lalafell"`| +|Miqo'te|`"Miqote"`| +|Roegadyn|`"Roegadyn"`| + +## Clan + +An enumerated type represented as a string. + +|Clan|String| +|---|---| +|Raen|`"Raen"`| +|Xaela|`"Xaela"`| +|Duskwight|`"Duskwight"`| +|Wildwood|`"Wildwood"`| +|Highlander|`"Highlander"`| +|Midlander|`"Midlander"`| +|Dunesfolk|`"Dunesfolk"`| +|Plainsfolk|`"Plainsfolk"`| +|Seeker of the Moon|`"SeekerOfTheMoon"`| +|Seeker of the Sun|`"SeekerOfTheSun"`| +|Hellsguard|`"Hellsguard"`| +|Sea Wolf|`"SeaWolf"`| + +## Gender + +An enumerated type represented as a string. + +|Gender|String| +|---|---| +|Male|`"Male"`| +|Female|`"Female"`| + +## Guardian + +An enumerated type represented as a string. + +|Guardian|String| +|---|---| +|Althyk|`"Althyk"`| +|Azeyma|`"Azeyma"`| +|Byregot|`"Byregot"`| +|Halone|`"Halone"`| +|Llymlaen|`"Llymlaen"`| +|Menphina|`"Menphina"`| +|Nald'thal|`"NaldThal"`| +|Nophica|`"Nophica"`| +|Nymeia|`"Nymeia"`| +|Oschon|`"Oschon"`| +|Rhalgr|`"Rhalgr"`| +|Thaliak|`"Thaliak"`| + +## CityState + +An enumerated type represented as a string. + +|City-state|String| +|---|---| +|Gridania|`"Gridania"`| +|Limsa Lominsa|`"LimsaLominsa"`| +|Ul'dah|`"UlDah"`| + +## GrandCompanyInfo + +|Key|Value|Description| +|---|---|---| +|`grand_company`|`GrandCompany`|The Grand Company. See GrandCompany section below.| +|`rank`|`String`|The character's rank in the Grand Company. Ex. `"Second Serpent Lieutenant"`| + +## GrandCompany + +An enumerated type represented as a string. + +|Grand Company|String| +|---|---| +|Immortal Flames|`"Flames"`| +|Maelstrom|`"Maelstrom"`| +|Order of the Twin Adder|`"TwinAdders"`| diff --git a/schemas/FreeCompany.md b/schemas/FreeCompany.md index e68a843..4092fd7 100644 --- a/schemas/FreeCompany.md +++ b/schemas/FreeCompany.md @@ -2,6 +2,7 @@ |Key|Value|Description| |---|---|---| +|`id`|`u64`|The Free Company's ID on the Lodestone.| |`name`|`String`|The name of the Free Company.| |`world`|`String`|The world the Free Company is on.| |`slogan`|`String`|The Free Company's slogan.| diff --git a/src/logic.rs b/src/logic.rs index e6b1485..faa2830 100644 --- a/src/logic.rs +++ b/src/logic.rs @@ -1,3 +1,7 @@ +use crate::error::*; + +use scraper::Html; + macro_rules! selectors { ($($name:ident => $selector:expr);+$(;)?) => { lazy_static! { @@ -10,3 +14,13 @@ macro_rules! selectors { pub mod character; pub mod free_company; + +crate fn plain_parse(html: &Html, select: &scraper::Selector) -> Result { + let string = html + .select(select) + .next() + .ok_or(Error::missing_element(select))? + .text() + .collect(); + Ok(string) +} diff --git a/src/logic/character.rs b/src/logic/character.rs index 902c023..4741258 100644 --- a/src/logic/character.rs +++ b/src/logic/character.rs @@ -1,12 +1,18 @@ -use crate::models::character::{ - Character, - CityState, - Gender, - GrandCompany, - GrandCompanyInfo, +use crate::{ + error::*, + logic::plain_parse, + models::{ + GrandCompany, + character::{ + Character, + CityState, + Gender, + GrandCompanyInfo, + }, + } }; -use ffxiv_types::{Race, Clan, Guardian}; +use ffxiv_types::{World, Race, Clan, Guardian}; use scraper::Html; @@ -25,69 +31,26 @@ selectors!( PROFILE_TEXT => ".character__selfintroduction"; ); -pub fn parse(id: u64, html: &str) -> Option { +pub fn parse(id: u64, html: &str) -> Result { let html = Html::parse_document(html); - let name = html.select(&*PROFILE_NAME).next()?.text().collect(); + let name = plain_parse(&html, &*PROFILE_NAME)?; + let world = parse_world(&html)?; + let title = parse_title(&html); + let (race, clan, gender) = parse_rcg(&html)?; - let world_str: String = html.select(&*PROFILE_WORLD).next()?.text().collect(); - let world = ffxiv_types::World::from_str(&world_str).ok()?; + let name_day = plain_parse(&html, &*PROFILE_NAME_DAY)?; + let guardian = parse_guardian(&html)?; - let title: Option = html - .select(&*PROFILE_TITLE) - .next() - .map(|x| x.text().collect()); + let city_state = parse_city_state(&html)?; - let mut rcg = html.select(&*PROFILE_RACE_CLAN_GENDER).next()?.text(); + let grand_company = parse_grand_company(&html)?; - let race = Race::from_str(rcg.next()?).ok()?; + let free_company_id = parse_free_company_id(&html)?; - let mut clan_gender_str = rcg.next()?.split(" / "); + let profile_text = plain_parse(&html, &*PROFILE_TEXT)?.trim().to_string(); - let clan = Clan::from_str(clan_gender_str.next()?).ok()?; - - let gender = Gender::parse(clan_gender_str.next()?)?; - - let name_day = html.select(&*PROFILE_NAME_DAY).next()?.text().collect(); - - let guardian_str: String = html.select(&*PROFILE_GUARDIAN).next()?.text().collect(); - let guardian = Guardian::from_str(guardian_str.split(",").next()?).ok()?; - - let city_state_str: String = html.select(&*PROFILE_CITY_STATE).next()?.text().collect(); - let city_state = CityState::parse(&city_state_str)?; - - let grand_company: Option = html - .select(&*PROFILE_GRAND_COMPANY) - .next() - .map(|x| x.text().collect::()) - .and_then(|x| { - let mut x = x.split(" / "); - let gc = GrandCompany::parse(x.next()?)?; - Some(GrandCompanyInfo { - grand_company: gc, - rank: x.next()?.to_string(), - }) - }); - - let free_company_id: Option = html - .select(&*PROFILE_FREE_COMPANY) - .next() - .and_then(|x| x.value().attr("href")) - .and_then(|x| x - .split('/') - .filter(|x| !x.is_empty()) - .last()) - .and_then(|x| x.parse().ok()); - - let profile_text = html - .select(&*PROFILE_TEXT) - .next()? - .text() - .collect::() - .trim() - .to_string(); - - Some(Character { + Ok(Character { id, name, world, @@ -103,3 +66,107 @@ pub fn parse(id: u64, html: &str) -> Option { profile_text, }) } + +fn parse_world(html: &Html) -> Result { + let world_str = plain_parse(&html, &*PROFILE_WORLD)?; + World::from_str(&world_str) + .map_err(|_| Error::invalid_content("valid world", Some(&world_str))) +} + +fn parse_title(html: &Html) -> Option { + html + .select(&*PROFILE_TITLE) + .next() + .map(|x| x.text().collect()) +} + +fn parse_rcg(html: &Html) -> Result<(Race, Clan, Gender)> { + let mut rcg = html + .select(&*PROFILE_RACE_CLAN_GENDER) + .next() + .ok_or_else(|| Error::missing_element(&*PROFILE_RACE_CLAN_GENDER))? + .text(); + + let race_str = rcg + .next() + .ok_or_else(|| Error::invalid_content("first of two parts in race/gender", None))?; + let race = Race::from_str(race_str) + .map_err(|_| Error::invalid_content("valid race", Some(race_str)))?; + + let clan_gender_str = rcg + .next() + .ok_or_else(|| Error::invalid_content("second of two parts in race/gender", None))?; + let mut clan_gender_split = clan_gender_str.split(" / "); + + let clan_str = clan_gender_split + .next() + .ok_or_else(|| Error::invalid_content("clan/gender split by `/`", Some(clan_gender_str)))?; + let clan = Clan::from_str(clan_str) + .map_err(|_| Error::invalid_content("valid clan", Some(clan_str)))?; + + let gender_str = clan_gender_split + .next() + .ok_or_else(|| Error::invalid_content("clan/gender split by `/`", Some(clan_gender_str)))?; + let gender = Gender::parse(gender_str) + .ok_or_else(|| Error::invalid_content("valid gender", Some(gender_str)))?; + + Ok((race, clan, gender)) +} + +fn parse_guardian(html: &Html) -> Result { + let guardian_str = plain_parse(&html, &*PROFILE_GUARDIAN)?; + guardian_str + .split(",") + .next() + .ok_or_else(|| Error::invalid_content("first part of guardian", Some(&guardian_str))) + .and_then(|x| Guardian::from_str(&x) + .map_err(|_| Error::invalid_content("valid guardian", Some(&guardian_str)))) +} + +fn parse_city_state(html: &Html) -> Result { + let city_state_str = plain_parse(&html, &*PROFILE_CITY_STATE)?; + CityState::parse(&city_state_str) + .ok_or_else(|| Error::invalid_content("valid city-state", Some(&city_state_str))) +} + +fn parse_grand_company(html: &Html) -> Result> { + let text = html + .select(&*PROFILE_GRAND_COMPANY) + .next() + .map(|x| x.text().collect::()); + let text = match text { + Some(t) => t, + None => return Ok(None), + }; + let mut x = text.split(" / "); + let gc_str = x + .next() + .ok_or_else(|| Error::invalid_content("gc/rank separated by `/`", Some(&text)))?; + let grand_company = GrandCompany::parse(gc_str) + .ok_or_else(|| Error::invalid_content("valid grand company", Some(&text)))?; + let rank = x + .next() + .ok_or_else(|| Error::invalid_content("gc/rank separated by `/`", Some(&text)))? + .to_string(); + Ok(Some(GrandCompanyInfo { + grand_company, + rank, + })) +} + +fn parse_free_company_id(html: &Html) -> Result> { + let elem = match html + .select(&*PROFILE_FREE_COMPANY) + .next() + { + Some(e) => e, + None => return Ok(None), + }; + let href = elem.value().attr("href").ok_or_else(|| Error::invalid_content("href on FC link", None))?; + let last = href + .split('/') + .filter(|x| !x.is_empty()) + .last() + .ok_or_else(|| Error::invalid_content("href separated by `/`", Some(&href)))?; + last.parse().map(Some).map_err(Error::InvalidNumber) +} diff --git a/src/logic/free_company.rs b/src/logic/free_company.rs index 030363b..70abd12 100644 --- a/src/logic/free_company.rs +++ b/src/logic/free_company.rs @@ -1,6 +1,8 @@ use crate::{ error::*, - models::free_company::{FreeCompany, PvpRankings, Estate, GrandCompany}, + logic::plain_parse, + models::GrandCompany, + models::free_company::{FreeCompany, PvpRankings, Estate}, }; use chrono::{DateTime, Local, TimeZone, Utc}; @@ -59,6 +61,7 @@ pub fn parse(id: u64, html: &str) -> Result { let reputation = parse_reputation(&html)?; Ok(FreeCompany { + id, name, world, slogan, @@ -73,16 +76,6 @@ pub fn parse(id: u64, html: &str) -> Result { }) } -fn plain_parse(html: &Html, select: &scraper::Selector) -> Result { - let string = html - .select(select) - .next() - .ok_or(Error::missing_element(select))? - .text() - .collect(); - Ok(string) -} - fn parse_world(html: &Html) -> Result { let world_str = plain_parse(html, &*FC_WORLD)?; let trimmed = world_str.trim(); diff --git a/src/models.rs b/src/models.rs index 9407f6b..7d2b831 100644 --- a/src/models.rs +++ b/src/models.rs @@ -26,3 +26,12 @@ macro_rules! ffxiv_enum { pub mod character; pub mod free_company; + +ffxiv_enum!( + #[derive(PartialEq, Eq, PartialOrd, Ord)] + GrandCompany { + Flames => "Immortal Flames", + Maelstrom => "Maelstrom", + TwinAdders => "Order of the Twin Adder", + } +); diff --git a/src/models/character.rs b/src/models/character.rs index 06d4c56..d57f63b 100644 --- a/src/models/character.rs +++ b/src/models/character.rs @@ -1,3 +1,5 @@ +use super::GrandCompany; + use ffxiv_types::{World, Race, Clan, Guardian}; #[derive(Debug, Serialize)] @@ -32,12 +34,6 @@ ffxiv_enum!(Gender { Female => "♀", }); -ffxiv_enum!(GrandCompany { - Flames => "Immortal Flames", - Maelstrom => "Maelstrom", - TwinAdders => "Order of the Twin Adder", -}); - ffxiv_enum!(CityState { Gridania => "Gridania", LimsaLominsa => "Limsa Lominsa", diff --git a/src/models/free_company.rs b/src/models/free_company.rs index 52a298a..13ec68d 100644 --- a/src/models/free_company.rs +++ b/src/models/free_company.rs @@ -1,3 +1,5 @@ +use super::GrandCompany; + use chrono::{DateTime, Utc}; use ffxiv_types::World; @@ -8,6 +10,7 @@ use std::collections::BTreeMap; #[derive(Debug, Serialize)] pub struct FreeCompany { + pub id: u64, pub name: String, pub world: World, pub slogan: String, @@ -22,15 +25,6 @@ pub struct FreeCompany { pub reputation: BTreeMap, } -ffxiv_enum!( - #[derive(PartialEq, Eq, PartialOrd, Ord)] - GrandCompany { - Flames => "Immortal Flames", - Maelstrom => "Maelstrom", - TwinAdders => "Order of the Twin Adder", - } -); - #[derive(Debug, Serialize)] pub struct PvpRankings { pub weekly: Option,