1use std::collections::HashMap;
2
3use jiff::civil::{Date, DateTime};
4use scraper::{Html, Selector};
5use serde::{Deserialize, Serialize};
6use tracing::error;
7use url::Url;
8
9mod error;
10mod parse_citizen_other;
11mod parse_citizen_profile;
12mod parse_org;
13mod parse_web_bio;
14
15pub use error::*;
16
17use crate::{
18 parse_citizen_other::{parse_citizen_probation, parse_citizen_record_id, parse_other},
19 parse_citizen_profile::parse_profile_info,
20 parse_org::parse_org,
21 parse_web_bio::parse_web_bio,
22};
23
24#[derive(Debug, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub struct Citizen {
27 pub moniker: String,
28 pub handle: String,
29 pub title: Title,
30 pub enlisted: Date,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub citizen_record_number: Option<u64>,
33 pub avatar: Url,
34 #[serde(default, skip_serializing_if = "Vec::is_empty")]
35 pub location: Vec<String>,
36 #[serde(default, skip_serializing_if = "Vec::is_empty")]
37 pub fluency: Vec<String>,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub website: Option<String>,
40 #[serde(default, skip_serializing_if = "Vec::is_empty")]
41 pub bio: Vec<String>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub probation: Option<DateTime>,
44}
45
46#[derive(Debug, Serialize, Deserialize)]
47pub struct Title {
48 pub icon: Url,
49 pub value: String,
50}
51
52#[derive(Debug, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54pub enum Organization {
55 Visible {
56 logo: Box<Url>,
57 name: String,
58 sid: String,
59 url: Box<Url>,
60 rank: OrganizationRank,
61 member_count: u64,
62 },
63 Redacted,
64}
65
66#[derive(Debug, Serialize, Deserialize)]
67pub struct OrganizationRank {
68 pub name: String,
69 pub value: u8,
70}
71
72#[derive(Debug, Serialize, Deserialize)]
73pub struct Organizations {
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub main: Option<Organization>,
76 #[serde(default, skip_serializing_if = "Vec::is_empty")]
77 pub affiliates: Vec<Organization>,
78}
79
80#[derive(PartialEq, Eq, Hash, Clone)]
81pub(crate) enum OrganizationType {
82 Main,
83 Affiliate,
84}
85
86pub fn parse_citizen_record<'a>(html_data: &str) -> Result<Citizen, ScScrapingError<'a>> {
87 let html = Html::parse_document(html_data);
88
89 let citizen_selector = Selector::parse("div.public-profile").unwrap();
90 let citizen = html.select(&citizen_selector).next().ok_or_else(|| {
91 let message = "Citizen content is empty";
92 error!("{message}");
93 ScScrapingError::citizen(message)
94 })?;
95
96 let web_bio = parse_web_bio(&citizen)?;
97 let profile_info = parse_profile_info(&citizen)?;
98 let other_data = parse_other(&citizen)?;
99
100 let record = Citizen {
101 moniker: profile_info.moniker,
102 handle: profile_info.handle,
103 title: profile_info.title,
104 avatar: profile_info.avatar,
105
106 website: web_bio.website,
107 bio: web_bio.bio,
108
109 enlisted: other_data.enlisted,
110 location: other_data.location,
111 fluency: other_data.languages,
112
113 citizen_record_number: parse_citizen_record_id(&citizen),
114
115 probation: parse_citizen_probation(&citizen),
116 };
117
118 Ok(record)
119}
120
121pub fn parse_organizations<'a>(
122 html_data: &str,
123) -> Result<Option<Organizations>, ScScrapingError<'a>> {
124 let html = Html::parse_document(html_data);
125
126 let org_selector = Selector::parse("div.box-content.org").unwrap();
127
128 let mut map = HashMap::new();
129
130 for e in html.select(&org_selector) {
131 let (org_type, org) = parse_org(&e)?;
132
133 if !map.contains_key(&org_type) {
134 map.insert(org_type.clone(), Vec::new());
135 }
136
137 map.get_mut(&org_type).unwrap().push(org);
138 }
139
140 if map.is_empty() {
141 return Ok(None);
142 }
143
144 if map.contains_key(&OrganizationType::Main)
145 && let Some(main) = map.get(&OrganizationType::Main)
146 && main.len() > 1
147 {
148 let message = "Cannot have multiple main organizations";
149 error!("{message}");
150 return Err(ScScrapingError::organization(message));
151 }
152
153 let orgs = Organizations {
154 main: map
155 .remove(&OrganizationType::Main)
156 .map(|mut o| o.pop().unwrap()),
157
158 affiliates: map.remove(&OrganizationType::Affiliate).unwrap_or_default(),
159 };
160
161 Ok(Some(orgs))
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn test() {
170 let test_profile_data = include_str!("./test-data/test-profile-probation.html");
171 let test_org_data = include_str!("./test-data/test-orgs.html");
172
173 let citizen = parse_citizen_record(test_profile_data).unwrap();
174 let orgs = parse_organizations(test_org_data).unwrap();
175
176 println!("{citizen:#?}");
179 println!("{orgs:#?}");
180 }
181}