From aa7c38634d03a458dec1857e4193a94d1e7ab814 Mon Sep 17 00:00:00 2001 From: Joshua Goins Date: Sat, 28 Jun 2025 12:11:40 -0400 Subject: [PATCH] Begin extracting classjob ids, current and max EXP --- src/lib.rs | 16 ++++++++++-- src/parser.rs | 72 ++++++++++++++++++++++++++++++--------------------- src/value.rs | 66 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 32 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 7eb0f42..7074473 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,7 +12,6 @@ use crate::parser::parse_search; use base64::prelude::*; use data::{Appearance, Currencies}; use package::Package; -use physis::race::{Gender, Race, Tribe}; use physis::savedata::chardat; use regex::Regex; use reqwest::Url; @@ -132,7 +131,20 @@ pub async fn archive_character(id: u64, use_dalamud: bool) -> Result, Ar .map_err(|_| ArchiveError::DownloadFailed(char_page_url.to_string()))?; let char_page = String::from_utf8(char_page).map_err(|_| ArchiveError::ParsingError)?; - let mut char_data = parser::parse_lodestone(&char_page); + let mut char_data = CharacterData::default(); + parser::parse_profile(&char_page, &mut char_data); + + let classjob_page_url = Url::parse(&format!( + "{lodestone_host}/lodestone/character/{}/class_job/", + id + )) + .map_err(|_| ArchiveError::UnknownError)?; + let classjob_page = download(&classjob_page_url) + .await + .map_err(|_| ArchiveError::DownloadFailed(classjob_page_url.to_string()))?; + let char_page = String::from_utf8(classjob_page).map_err(|_| ArchiveError::ParsingError)?; + + parser::parse_classjob(&char_page, &mut char_data); // 2 MiB, for one JSON and two images let mut buf = Vec::new(); diff --git a/src/parser.rs b/src/parser.rs index 9d60f97..501b560 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -41,16 +41,13 @@ const CHARACTER_BLOCK_NAME_SELECTOR: &str = ".character-block__name"; const FACE_IMG_SELECTOR: &str = ".frame__chara__face > img"; const PORTRAIT_IMG_SELECTOR: &str = ".character__detail__image > a > img"; const NAMEDAY_SELECTOR: &str = ".character-block__birth"; -const CLASSJOB_SELECTOR: &str = ".character__level__list > ul > li"; const FREE_COMPANY_SELECTOR: &str = ".character__freecompany__name > h4 > a"; const TITLE_SELECTOR: &str = ".frame__chara__title"; /// Parses the HTML from `data` and returns `CharacterData`. The data may be incomplete. -pub fn parse_lodestone(data: &str) -> CharacterData { +pub fn parse_profile(data: &str, char_data: &mut CharacterData) { let document = Html::parse_document(data); - let mut char_data = CharacterData::default(); - for element in document.select(&Selector::parse(CHARACTER_NAME_SELECTOR).unwrap()) { char_data.name = element.inner_html(); } @@ -148,31 +145,6 @@ pub fn parse_lodestone(data: &str) -> CharacterData { char_data.portrait_url = element.attr("src").unwrap().parse().unwrap(); } - for element in document.select(&Selector::parse(CLASSJOB_SELECTOR).unwrap()) { - let img = element.first_child().unwrap(); - let name = img - .value() - .as_element() - .unwrap() - .attr("data-tooltip") - .unwrap(); - - // ignore "-" and other invalid level values - if let Ok(level) = element - .last_child() - .unwrap() - .value() - .as_text() - .unwrap() - .parse::() - { - char_data.classjob_levels.push(ClassJobValue { - name: name.to_string(), - level, - }); - } - } - // TODO: support facewear let item_slot_selectors = [ ".icon-c--0", // Main Hand @@ -215,8 +187,48 @@ pub fn parse_lodestone(data: &str) -> CharacterData { } } } +} - char_data +const CLASSJOB_SELECTOR: &str = ".character__job > li"; +const CLASSJOB_LEVEL_SELECTOR: &str = ".character__job__level"; +const CLASSJOB_NAME_SELECTOR: &str = ".character__job__name"; +const CLASSJOB_EXP_SELECTOR: &str = ".character__job__exp"; + +/// Parses the HTML from `data` and returns `CharacterData`. The data may be incomplete. +pub fn parse_classjob(data: &str, char_data: &mut CharacterData) { + let document = Html::parse_document(data); + + for element in document.select(&Selector::parse(CLASSJOB_SELECTOR).unwrap()) { + let level = element + .select(&Selector::parse(CLASSJOB_LEVEL_SELECTOR).unwrap()) + .nth(0) + .unwrap(); + let name = element + .select(&Selector::parse(CLASSJOB_NAME_SELECTOR).unwrap()) + .nth(0) + .unwrap(); + let exp_element = element + .select(&Selector::parse(CLASSJOB_EXP_SELECTOR).unwrap()) + .nth(0) + .unwrap(); + + let mut exp = None; + let mut max_exp = None; + if let Some((exp_text, max_exp_text)) = exp_element.inner_html().split_once(" / ") { + exp = exp_text.replace(",", "").parse().ok(); + max_exp = max_exp_text.replace(",", "").parse().ok(); + } + + // skip levels that are -, which means they don't even have the classjob + if let Ok(level) = level.inner_html().parse() { + let mut class_job_value = + ClassJobValue::try_from(name.inner_html().as_str()).unwrap_or_default(); + class_job_value.level = level; + class_job_value.exp = exp; + class_job_value.max_exp = max_exp; + char_data.classjob_levels.push(class_job_value); + } + } } fn parse_item_tooltip(element: &scraper::ElementRef<'_>) -> Option { diff --git a/src/value.rs b/src/value.rs index de79282..b6208ea 100644 --- a/src/value.rs +++ b/src/value.rs @@ -299,6 +299,72 @@ pub struct ClassJobValue { pub name: String, /// Level of the class or job. pub level: i32, + /// The EXP of the job, can be None to indicate either: the job isn't unlocked or that they have max EXP. + #[serde(skip_serializing_if = "Option::is_none")] + pub exp: Option, + /// The maximum EXP of the job, can be None to indicate either: the job isn't unlocked or that they have max EXP. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_exp: Option, + /// Internal ID of the class or job. + pub value: i32, +} + +impl TryFrom<&str> for ClassJobValue { + type Error = ArchiveError; + + fn try_from(name: &str) -> Result { + let value = match name { + "Gladiator" => 1, + "Pugilist" => 2, + "Marauder" => 3, + "Lancer" => 4, + "Archer" => 5, + "Conjurer" => 6, + "Thaumaturge" => 7, + "Carpenter" => 8, + "Blacksmith" => 9, + "Armorer" => 10, + "Goldsmith" => 11, + "Leatherworker" => 12, + "Weaver" => 13, + "Alchemist" => 14, + "Culinarian" => 15, + "Miner" => 16, + "Botanist" => 17, + "Fisher" => 18, + "Paladin" => 19, + "Monk" => 20, + "Warrior" => 21, + "Dragoon" => 22, + "Bard" => 23, + "White Mage" => 24, + "Black Mage" => 25, + "Arcanist" => 26, + "Summoner" => 27, + "Scholar" => 28, + "Rogue" => 29, + "Ninja" => 30, + "Machinist" => 31, + "Dark Knight" => 32, + "Astrologian" => 33, + "Samurai" => 34, + "Red Mage" => 35, + "Blue Mage" => 36, + "Gunbreaker" => 37, + "Dancer" => 38, + "Reaper" => 39, + "Sage" => 40, + "Viper" => 41, + "Pictomancer" => 42, + _ => return Err(ArchiveError::ParsingError), + }; + + Ok(Self { + name: name.to_string(), + value, + ..Default::default() + }) + } } #[derive(Default, Serialize)]