From 98dd9192ec85ebfc5420807931e87476344338f1 Mon Sep 17 00:00:00 2001 From: Anna Date: Mon, 3 Sep 2018 16:40:30 -0400 Subject: [PATCH] feat: add character search --- src/logic.rs | 41 ++++++++++- src/logic/character.rs | 36 ++-------- src/logic/search.rs | 44 ++++++++++++ src/logic/search/character.rs | 126 +++++++++++++++++++++++++++++++++ src/models.rs | 1 + src/models/search.rs | 14 ++++ src/models/search/character.rs | 16 +++++ 7 files changed, 247 insertions(+), 31 deletions(-) create mode 100644 src/logic/search.rs create mode 100644 src/logic/search/character.rs create mode 100644 src/models/search.rs create mode 100644 src/models/search/character.rs diff --git a/src/logic.rs b/src/logic.rs index faa2830..dafeed8 100644 --- a/src/logic.rs +++ b/src/logic.rs @@ -1,6 +1,15 @@ -use crate::error::*; +use crate::{ + error::*, + models::{ + GrandCompany, + character::GrandCompanyInfo, + }, +}; -use scraper::Html; +use scraper::{ + Html, + node::Element, +}; macro_rules! selectors { ($($name:ident => $selector:expr);+$(;)?) => { @@ -14,6 +23,7 @@ macro_rules! selectors { pub mod character; pub mod free_company; +pub mod search; crate fn plain_parse(html: &Html, select: &scraper::Selector) -> Result { let string = html @@ -24,3 +34,30 @@ crate fn plain_parse(html: &Html, select: &scraper::Selector) -> Result .collect(); Ok(string) } + +crate fn parse_id<'a>(a: &'a Element) -> Result { + let href = a.attr("href").ok_or_else(|| Error::invalid_content("href on 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_err(Error::InvalidNumber) +} + +crate fn parse_grand_company(text: &str) -> Result { + 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(GrandCompanyInfo { + grand_company, + rank, + }) +} diff --git a/src/logic/character.rs b/src/logic/character.rs index 4741258..55709dc 100644 --- a/src/logic/character.rs +++ b/src/logic/character.rs @@ -1,14 +1,11 @@ use crate::{ error::*, logic::plain_parse, - models::{ - GrandCompany, - character::{ - Character, - CityState, - Gender, - GrandCompanyInfo, - }, + models::character::{ + Character, + CityState, + Gender, + GrandCompanyInfo, } }; @@ -138,20 +135,7 @@ fn parse_grand_company(html: &Html) -> Result> { 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, - })) + crate::logic::parse_grand_company(&text).map(Some) } fn parse_free_company_id(html: &Html) -> Result> { @@ -162,11 +146,5 @@ fn parse_free_company_id(html: &Html) -> Result> { 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) + crate::logic::parse_id(elem.value()).map(Some) } diff --git a/src/logic/search.rs b/src/logic/search.rs new file mode 100644 index 0000000..4827ab8 --- /dev/null +++ b/src/logic/search.rs @@ -0,0 +1,44 @@ +use crate::{ + error::*, + models::search::Pagination, +}; + +use scraper::Html; + +pub mod character; + +selectors!( + PAGINATION_TOTAL => ".parts__total"; + PAGINATION_PAGES => ".btn__pager__current"; +); + +crate fn parse_pagination(html: &Html) -> Result { + let total_str = crate::logic::plain_parse(&html, &*PAGINATION_TOTAL)?; + let total_results: u64 = total_str + .split(' ') + .next() + .unwrap() // will always have a first element + .parse() + .map_err(Error::InvalidNumber)?; + + let pages_str = crate::logic::plain_parse(&html, &*PAGINATION_PAGES)?; + let mut pages_split = pages_str.split(' '); + + let current_page: u64 = pages_split + .nth(1) + .ok_or_else(|| Error::invalid_content("4 items in pages string", None))? + .parse() + .map_err(Error::InvalidNumber)?; + + let total_pages: u64 = pages_split + .nth(1) + .ok_or_else(|| Error::invalid_content("4 items in pages string", None))? + .parse() + .map_err(Error::InvalidNumber)?; + + Ok(Pagination { + current_page, + total_pages, + total_results, + }) +} diff --git a/src/logic/search/character.rs b/src/logic/search/character.rs new file mode 100644 index 0000000..08f4a90 --- /dev/null +++ b/src/logic/search/character.rs @@ -0,0 +1,126 @@ +use crate::{ + error::*, + models::{ + search::{ + Paginated, + character::CharacterSearchItem, + }, + character::GrandCompanyInfo, + } +}; + +use ffxiv_types::World; + +use scraper::{Html, ElementRef}; + +use url::Url; + +use std::str::FromStr; + +selectors!( + ITEM_ENTRY => ".ldst__window .entry"; + + ITEM_ID => ".entry__link"; + ITEM_FACE => ".entry__chara__face > img"; + ITEM_NAME => ".entry__name"; + ITEM_WORLD => ".entry__world"; + ITEM_GRAND_COMPANY => ".entry__chara_info .js__tooltip"; + ITEM_FREE_COMPANY => ".entry__freecompany__link"; +); + +pub fn parse(html: &str) -> Result> { + let html = Html::parse_document(html); + + let pagination = crate::logic::search::parse_pagination(&html)?; + + let results: Vec = html + .select(&*ITEM_ENTRY) + .map(|x| parse_single(x)) + .collect::>()?; + + Ok(Paginated { + pagination, + results, + }) +} + +fn parse_single<'a>(html: ElementRef<'a>) -> Result { + let id = parse_id(html)?; + + let name = plain_parse(html, &*ITEM_NAME)?; + let world = parse_world(html)?; + + let grand_company = parse_grand_company(html)?; + + let free_company_id = parse_free_company_id(html)?; + + let face = parse_face(html)?; + + Ok(CharacterSearchItem { + id, + name, + world, + grand_company, + free_company_id, + face, + }) +} + +fn plain_parse<'a>(html: ElementRef<'a>, select: &scraper::Selector) -> Result { + let string = html + .select(select) + .next() + .ok_or(Error::missing_element(select))? + .text() + .collect(); + Ok(string) +} + +fn parse_id<'a>(html: ElementRef<'a>) -> Result { + let e = html + .select(&*ITEM_ID) + .next() + .ok_or_else(|| Error::missing_element(&*ITEM_ID))?; + crate::logic::parse_id(e.value()) +} + +fn parse_world<'a>(html: ElementRef<'a>) -> Result { + let world_str = plain_parse(html, &*ITEM_WORLD)?; + World::from_str(&world_str) + .map_err(|_| Error::invalid_content("valid world", Some(&world_str))) +} + +fn parse_free_company_id<'a>(html: ElementRef<'a>) -> Result> { + let elem = match html + .select(&*ITEM_FREE_COMPANY) + .next() + { + Some(e) => e, + None => return Ok(None), + }; + crate::logic::parse_id(elem.value()).map(Some) +} + +fn parse_grand_company<'a>(html: ElementRef<'a>) -> Result> { + let text = html + .select(&*ITEM_GRAND_COMPANY) + .next() + .and_then(|x| x.value().attr("data-tooltip")); + let text = match text { + Some(t) => t, + None => return Ok(None), + }; + crate::logic::parse_grand_company(text).map(Some) +} + +fn parse_face<'a>(html: ElementRef<'a>) -> Result { + let face_elem = html + .select(&*ITEM_FACE) + .next() + .ok_or_else(|| Error::missing_element(&*ITEM_FACE))?; + let src = face_elem + .value() + .attr("src") + .ok_or_else(|| Error::invalid_content("src on face img element", None))?; + Url::from_str(src).map_err(Error::InvalidUrl) +} diff --git a/src/models.rs b/src/models.rs index 7d2b831..363dadb 100644 --- a/src/models.rs +++ b/src/models.rs @@ -26,6 +26,7 @@ macro_rules! ffxiv_enum { pub mod character; pub mod free_company; +pub mod search; ffxiv_enum!( #[derive(PartialEq, Eq, PartialOrd, Ord)] diff --git a/src/models/search.rs b/src/models/search.rs new file mode 100644 index 0000000..792f269 --- /dev/null +++ b/src/models/search.rs @@ -0,0 +1,14 @@ +pub mod character; + +#[derive(Debug, Serialize)] +pub struct Pagination { + pub current_page: u64, + pub total_pages: u64, + pub total_results: u64, +} + +#[derive(Debug, Serialize)] +pub struct Paginated { + pub pagination: Pagination, + pub results: Vec, +} diff --git a/src/models/search/character.rs b/src/models/search/character.rs new file mode 100644 index 0000000..5fa3cba --- /dev/null +++ b/src/models/search/character.rs @@ -0,0 +1,16 @@ +use crate::models::character::GrandCompanyInfo; + +use ffxiv_types::World; + +use url::Url; + +#[derive(Debug, Serialize)] +pub struct CharacterSearchItem { + pub id: u64, + pub name: String, + pub world: World, + pub grand_company: Option, + pub free_company_id: Option, + #[serde(with = "url_serde")] + pub face: Url, +}