1use scraper::{Html, Selector};
4use steamid::SteamID;
5
6use crate::{
7 client::SteamUser,
8 endpoint::steam_endpoint,
9 error::SteamUserError,
10 types::{OnlineState, PrivacyState, PublicProfileSummary, TradeBanState},
11};
12
13impl SteamUser {
14 #[steam_endpoint(GET, host = Community, path = "/dev/apikey", kind = Read)]
20 pub async fn get_web_api_key(&self, domain: &str) -> Result<String, SteamUserError> {
21 let response = self.get_path("/dev/apikey").send().await?;
22 self.check_response(&response)?;
23 let html = response.text().await?;
24
25 if html.contains("You will be granted access to Steam Web API keys when you have games in your Steam account.") {
26 return Err(SteamUserError::LimitedAccount("Account needs games to register API key".into()));
27 }
28
29 let key = parse_web_api_key(&html);
30 if let Some(k) = key {
31 return Ok(k);
32 }
33
34 let document = Html::parse_document(&html);
36 let form_selector = Selector::parse("form[action*='/dev/registerkey']").map_err(|_| SteamUserError::Other("Failed to parse selector".into()))?;
37
38 if document.select(&form_selector).next().is_some() {
39 let response = self.post_path("/dev/registerkey").form(&[("domain", domain), ("agreeToTerms", "agreed"), ("Submit", "Register")]).send().await?;
41 self.check_response(&response)?;
42
43 let html = response.text().await?;
44 if let Some(k) = parse_web_api_key(&html) {
46 return Ok(k);
47 }
48
49 return Err(SteamUserError::SteamError("Failed to register API key".into()));
51 }
52
53 Err(SteamUserError::SteamError("Failed to retrieve or register API key".into()))
54 }
55
56 #[steam_endpoint(POST, host = Community, path = "/dev/revokekey", kind = Write)]
58 pub async fn revoke_web_api_key(&self) -> Result<(), SteamUserError> {
59 let response = self.post_path("/dev/revokekey").form(&[("Revoke", "Revoke My Steam Web API Key")]).send().await?;
60 self.check_response(&response)?;
61
62 let html = response.text().await?;
66 if parse_web_api_key(&html).is_none() {
67 Ok(())
68 } else {
69 Err(SteamUserError::SteamError("Failed to revoke API key".into()))
70 }
71 }
72
73 #[steam_endpoint(GET, host = Api, path = "/ISteamUser/ResolveVanityURL/v0001/", kind = Read)]
81 pub async fn resolve_vanity_url(&self, api_key: &str, vanity_url_name: &str) -> Result<steamid::SteamID, SteamUserError> {
82 let params = [("key", api_key), ("vanityurl", vanity_url_name)];
83
84 let response = self.get_path("/ISteamUser/ResolveVanityURL/v0001/").query(¶ms).send().await?;
85 self.check_response(&response)?;
86
87 let text = response.text().await?;
88 let json: ResolveVanityUrlResponse = serde_json::from_str(&text).map_err(|e| SteamUserError::Other(format!("Failed to parse ResolveVanityURL response: {}", e)))?;
89
90 if json.response.success == 1 {
91 if let Some(steamid_str) = json.response.steamid {
92 return steamid_str.parse::<steamid::SteamID>().map_err(|e| SteamUserError::Other(format!("Failed to parse SteamID from response: {}", e)));
93 }
94 }
95
96 Err(SteamUserError::Other(json.response.message.unwrap_or_else(|| "Failed to resolve vanity URL".to_string())))
97 }
98
99 #[steam_endpoint(GET, host = Community, path = "/id/{vanity}/", kind = Read)]
111 pub async fn resolve_vanity_url_public(&self, vanity: &str) -> Result<PublicProfileSummary, SteamUserError> {
112 if vanity.is_empty() || !vanity.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
113 return Err(SteamUserError::InvalidInput(format!("invalid vanity slug: {vanity:?}")));
114 }
115 let path = format!("/id/{vanity}/?xml=1");
116 self.fetch_public_profile_xml(&path, vanity).await
117 }
118
119 #[steam_endpoint(GET, host = Community, path = "/profiles/{steam_id}/", kind = Read)]
122 pub async fn fetch_public_profile_by_id(&self, steam_id: SteamID) -> Result<PublicProfileSummary, SteamUserError> {
123 let id_str = steam_id.to_string();
124 let path = format!("/profiles/{id_str}/?xml=1");
125 self.fetch_public_profile_xml(&path, &id_str).await
126 }
127
128 #[tracing::instrument(skip(self), fields(path = %path, identifier = %identifier))]
130 async fn fetch_public_profile_xml(&self, path: &str, identifier: &str) -> Result<PublicProfileSummary, SteamUserError> {
131 let response = self.get_path(path).send().await?;
132 self.check_response(&response)?;
133 let body = response.text().await?;
134 parse_public_profile_xml(&body).ok_or_else(|| SteamUserError::Other(format!("profile not found: {identifier}")))
135 }
136}
137
138fn extract_tag<'a>(body: &'a str, tag: &str) -> Option<&'a str> {
141 let open = format!("<{tag}>");
142 let close = format!("</{tag}>");
143 let start = body.find(&open)? + open.len();
144 let end = body[start..].find(&close)?;
145 let inner = body[start..start + end].trim();
146 inner.strip_prefix("<![CDATA[").and_then(|s| s.strip_suffix("]]>")).map(str::trim).or(Some(inner))
147}
148
149fn extract_tag_opt<'a>(body: &'a str, tag: &str) -> Option<&'a str> {
152 extract_tag(body, tag).filter(|s| !s.is_empty())
153}
154
155fn parse_public_profile_xml(body: &str) -> Option<PublicProfileSummary> {
156 let steam_id = extract_tag(body, "steamID64")?.parse::<SteamID>().ok()?;
159
160 let privacy_state = extract_tag(body, "visibilityState").and_then(|s| s.parse::<i32>().ok()).map_or(PrivacyState::default(), PrivacyState::from);
163
164 Some(PublicProfileSummary {
165 steam_id,
166 persona_name: extract_tag(body, "steamID").unwrap_or("").to_owned(),
167 online_state: OnlineState::from_xml(extract_tag(body, "onlineState").unwrap_or("")),
168 state_message: extract_tag(body, "stateMessage").unwrap_or("").to_owned(),
169 privacy_state,
170 avatar_icon: extract_tag(body, "avatarIcon").unwrap_or("").to_owned(),
171 avatar_medium: extract_tag(body, "avatarMedium").unwrap_or("").to_owned(),
172 avatar_full: extract_tag(body, "avatarFull").unwrap_or("").to_owned(),
173 vac_banned: extract_tag(body, "vacBanned").is_some_and(|s| s == "1"),
174 trade_ban_state: TradeBanState::from_xml(extract_tag(body, "tradeBanState").unwrap_or("None")),
175 is_limited_account: extract_tag(body, "isLimitedAccount").is_some_and(|s| s == "1"),
176 custom_url: extract_tag_opt(body, "customURL").map(str::to_owned),
177 member_since: extract_tag_opt(body, "memberSince").and_then(|s| chrono::NaiveDate::parse_from_str(s, "%B %d, %Y").ok()).and_then(|d| d.and_hms_opt(0, 0, 0)).map(|naive| chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive, chrono::Utc)),
181 headline: extract_tag_opt(body, "headline").map(str::to_owned),
182 location: extract_tag_opt(body, "location").map(str::to_owned),
183 real_name: extract_tag_opt(body, "realname").map(str::to_owned),
184 summary: extract_tag_opt(body, "summary").map(str::to_owned),
185 hours_played_2wk: extract_tag_opt(body, "hoursPlayed2Wk").and_then(|s| s.parse().ok()),
186 })
187}
188
189fn parse_web_api_key(html: &str) -> Option<String> {
191 if html.contains("Revoke My Steam Web API Key") {
192 let fragment = Html::parse_fragment(html);
193 let p_selector = Selector::parse("#bodyContents_ex > p").ok()?;
194
195 for element in fragment.select(&p_selector) {
196 let text = element.text().collect::<String>();
197 if text.contains("Key:") {
198 let parts: Vec<&str> = text.split("Key:").collect();
199 if parts.len() > 1 {
200 return Some(parts[1].trim().to_string());
201 }
202 }
203 }
204 }
205 None
206}
207
208#[derive(serde::Deserialize)]
209struct ResolveVanityUrlResponse {
210 response: ResolveVanityUrlResponseInner,
211}
212
213#[derive(serde::Deserialize)]
214struct ResolveVanityUrlResponseInner {
215 steamid: Option<String>,
216 success: i32,
217 message: Option<String>,
218}