refactor: make character parsing use errors

Add schema for characters.
This commit is contained in:
Anna 2018-09-03 15:32:17 -04:00
parent 0220f1a1d5
commit db4c96040e
8 changed files with 266 additions and 88 deletions

104
schemas/Character.md Normal file
View File

@ -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"`|

View File

@ -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.|

View File

@ -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<String> {
let string = html
.select(select)
.next()
.ok_or(Error::missing_element(select))?
.text()
.collect();
Ok(string)
}

View File

@ -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<Character> {
pub fn parse(id: u64, html: &str) -> Result<Character> {
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<String> = 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<GrandCompanyInfo> = html
.select(&*PROFILE_GRAND_COMPANY)
.next()
.map(|x| x.text().collect::<String>())
.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<u64> = 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::<String>()
.trim()
.to_string();
Some(Character {
Ok(Character {
id,
name,
world,
@ -103,3 +66,107 @@ pub fn parse(id: u64, html: &str) -> Option<Character> {
profile_text,
})
}
fn parse_world(html: &Html) -> Result<World> {
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<String> {
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<Guardian> {
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<CityState> {
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<Option<GrandCompanyInfo>> {
let text = html
.select(&*PROFILE_GRAND_COMPANY)
.next()
.map(|x| x.text().collect::<String>());
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<Option<u64>> {
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)
}

View File

@ -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<FreeCompany> {
let reputation = parse_reputation(&html)?;
Ok(FreeCompany {
id,
name,
world,
slogan,
@ -73,16 +76,6 @@ pub fn parse(id: u64, html: &str) -> Result<FreeCompany> {
})
}
fn plain_parse(html: &Html, select: &scraper::Selector) -> Result<String> {
let string = html
.select(select)
.next()
.ok_or(Error::missing_element(select))?
.text()
.collect();
Ok(string)
}
fn parse_world(html: &Html) -> Result<World> {
let world_str = plain_parse(html, &*FC_WORLD)?;
let trimmed = world_str.trim();

View File

@ -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",
}
);

View File

@ -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",

View File

@ -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<GrandCompany, u8>,
}
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<u64>,