1use std::sync::OnceLock;
4
5use regex::Regex;
6use scraper::{Html, Selector};
7
8use crate::{
9 client::SteamUser,
10 endpoint::steam_endpoint,
11 error::SteamUserError,
12 types::{AccountDetails, PurchaseHistoryItem, RedeemWalletCodeResponse, TransactionId, WalletBalance},
13 utils::get_avatar_hash_from_url,
14};
15
16static SEL_BALANCE: OnceLock<Selector> = OnceLock::new();
17fn sel_balance() -> &'static Selector {
18 SEL_BALANCE.get_or_init(|| Selector::parse("#header_wallet_balance").expect("valid CSS selector"))
19}
20
21static SEL_TOOLTIP: OnceLock<Selector> = OnceLock::new();
22fn sel_tooltip() -> &'static Selector {
23 SEL_TOOLTIP.get_or_init(|| Selector::parse("span.tooltip").expect("valid CSS selector"))
24}
25
26static SEL_HELP_SPEND: OnceLock<Selector> = OnceLock::new();
27fn sel_help_spend() -> &'static Selector {
28 SEL_HELP_SPEND.get_or_init(|| Selector::parse(".help_event_limiteduser .help_event_limiteduser_spend").expect("valid CSS selector"))
29}
30
31static SEL_TITLE: OnceLock<Selector> = OnceLock::new();
32fn sel_title() -> &'static Selector {
33 SEL_TITLE.get_or_init(|| Selector::parse("title").expect("valid CSS selector"))
34}
35
36static SEL_WALLET_ROW: OnceLock<Selector> = OnceLock::new();
37fn sel_wallet_row() -> &'static Selector {
38 SEL_WALLET_ROW.get_or_init(|| Selector::parse(".wallet_table_row").expect("valid CSS selector"))
39}
40
41static SEL_WHT_DATE: OnceLock<Selector> = OnceLock::new();
42fn sel_wht_date() -> &'static Selector {
43 SEL_WHT_DATE.get_or_init(|| Selector::parse(".wht_date").expect("valid CSS selector"))
44}
45
46static SEL_WHT_TYPE: OnceLock<Selector> = OnceLock::new();
47fn sel_wht_type() -> &'static Selector {
48 SEL_WHT_TYPE.get_or_init(|| Selector::parse(".wht_type").expect("valid CSS selector"))
49}
50
51static SEL_WHT_ITEMS: OnceLock<Selector> = OnceLock::new();
52fn sel_wht_items() -> &'static Selector {
53 SEL_WHT_ITEMS.get_or_init(|| Selector::parse(".wht_items").expect("valid CSS selector"))
54}
55
56static SEL_WHT_TOTAL: OnceLock<Selector> = OnceLock::new();
57fn sel_wht_total() -> &'static Selector {
58 SEL_WHT_TOTAL.get_or_init(|| Selector::parse(".wht_total").expect("valid CSS selector"))
59}
60
61static SEL_WHT_BASE_PRICE: OnceLock<Selector> = OnceLock::new();
62fn sel_wht_base_price() -> &'static Selector {
63 SEL_WHT_BASE_PRICE.get_or_init(|| Selector::parse(".wht_base_price, .wht_base_price_discounted").expect("valid CSS selector"))
64}
65
66static SEL_WHT_TAX: OnceLock<Selector> = OnceLock::new();
67fn sel_wht_tax() -> &'static Selector {
68 SEL_WHT_TAX.get_or_init(|| Selector::parse(".wht_tax").expect("valid CSS selector"))
69}
70
71static SEL_WHT_SHIPPING: OnceLock<Selector> = OnceLock::new();
72fn sel_wht_shipping() -> &'static Selector {
73 SEL_WHT_SHIPPING.get_or_init(|| Selector::parse(".wht_shipping").expect("valid CSS selector"))
74}
75
76static SEL_WHT_WALLET_CHANGE: OnceLock<Selector> = OnceLock::new();
77fn sel_wht_wallet_change() -> &'static Selector {
78 SEL_WHT_WALLET_CHANGE.get_or_init(|| Selector::parse(".wht_wallet_change").expect("valid CSS selector"))
79}
80
81static SEL_WHT_WALLET: OnceLock<Selector> = OnceLock::new();
82fn sel_wht_wallet() -> &'static Selector {
83 SEL_WHT_WALLET.get_or_init(|| Selector::parse(".wht_wallet_balance").expect("valid CSS selector"))
84}
85
86static SEL_WTH_PAYMENT: OnceLock<Selector> = OnceLock::new();
87fn sel_wth_payment() -> &'static Selector {
88 SEL_WTH_PAYMENT.get_or_init(|| Selector::parse(".wth_payment").expect("valid CSS selector"))
89}
90
91static SEL_PLAYER_AVATAR_IMG: OnceLock<Selector> = OnceLock::new();
92fn sel_player_avatar_img() -> &'static Selector {
93 SEL_PLAYER_AVATAR_IMG.get_or_init(|| Selector::parse(".playerAvatar img").expect("valid CSS selector"))
94}
95
96static RE_CURRENCY_END: OnceLock<Regex> = OnceLock::new();
97fn re_currency_end() -> &'static Regex {
98 RE_CURRENCY_END.get_or_init(|| Regex::new(r"([^\d.,\s].*)$").expect("valid regex"))
99}
100
101static RE_CURRENCY_START: OnceLock<Regex> = OnceLock::new();
102fn re_currency_start() -> &'static Regex {
103 RE_CURRENCY_START.get_or_init(|| Regex::new(r"^([^\d.,\s]+)").expect("valid regex"))
104}
105
106static RE_PENDING: OnceLock<Regex> = OnceLock::new();
107fn re_pending() -> &'static Regex {
108 RE_PENDING.get_or_init(|| Regex::new(r"Pending:\s*([\d.,]+[^\s]*)").expect("valid regex"))
109}
110
111static RE_TRANSID: OnceLock<Regex> = OnceLock::new();
112fn re_transid() -> &'static Regex {
113 RE_TRANSID.get_or_init(|| Regex::new(r"transid=(\d+)").expect("valid regex"))
114}
115
116pub(crate) fn parse_wallet_balance(document: &Html) -> WalletBalance {
122 let mut main_balance = None;
123 let mut currency = None;
124 let mut pending = None;
125
126 if let Some(el) = document.select(sel_balance()).next() {
127 let text: String = el.children().filter_map(|n| n.value().as_text().map(|t| t.to_string())).collect::<String>().trim().to_string();
129
130 if !text.is_empty() {
131 if let Some(caps) = re_currency_end().captures(&text) {
133 currency = Some(caps[1].trim().to_string());
134 } else if let Some(caps) = re_currency_start().captures(&text) {
135 currency = Some(caps[1].trim().to_string());
136 }
137
138 main_balance = Some(text);
139 }
140
141 if let Some(tip) = el.select(sel_tooltip()).next() {
143 let tip_text = tip.text().collect::<String>();
144 if let Some(caps) = re_pending().captures(&tip_text) {
145 pending = Some(caps[1].to_string());
146 }
147 }
148 }
149
150 WalletBalance { main_balance, pending, currency }
151}
152
153impl SteamUser {
154 #[tracing::instrument(skip(self))]
186 pub async fn get_steam_wallet_balance(&self) -> Result<WalletBalance, SteamUserError> {
187 let details = self.get_account_details().await?;
188 details.wallet_balance.ok_or_else(|| SteamUserError::Other("Wallet balance not found".into()))
189 }
190
191 #[steam_endpoint(GET, host = Help, path = "/en/", kind = Read)]
213 pub async fn get_amount_spent_on_steam(&self) -> Result<String, SteamUserError> {
214 let response = self.get_path("/en/").send().await?.text().await?;
215
216 let document = Html::parse_document(&response);
217
218 if let Some(el) = document.select(sel_help_spend()).next() {
219 let text = el.text().collect::<String>().trim().to_string();
220 let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
222
223 if text.starts_with("Amount Spent on Steam:") {
224 return Ok(text.replace("Amount Spent on Steam:", "").trim().to_string());
225 }
226 }
227
228 Err(SteamUserError::Other("Amount spent information not found on help page".into()))
229 }
230
231 #[steam_endpoint(POST, host = Community, path = "/parental/ajaxunlock", kind = Auth)]
259 pub async fn parental_unlock(&self, pin: &str) -> Result<(), SteamUserError> {
260 let response: serde_json::Value = self.post_path("/parental/ajaxunlock").form(&[("pin", pin)]).send().await?.json().await?;
261
262 let result = Self::check_json_success(&response, "Failed to unlock parental controls");
263
264 match result {
265 Ok(_) => Ok(()),
266 Err(SteamUserError::EResult { code, .. }) => match code {
267 15 => Err(SteamUserError::Other("Incorrect PIN".into())),
268 25 => Err(SteamUserError::Other("Too many invalid PIN attempts".into())),
269 _ => Err(SteamUserError::from_eresult(code)),
270 },
271 Err(e) => Err(e),
272 }
273 }
274
275 #[steam_endpoint(GET, host = Store, path = "/account/history/", kind = Read)]
301 pub async fn get_purchase_history(&self) -> Result<Vec<PurchaseHistoryItem>, SteamUserError> {
302 let response = self.get_path("/account/history/").send().await?.text().await?;
303
304 Self::parse_purchase_history_html(&response)
305 }
306
307 pub fn parse_purchase_history_html(html: &str) -> Result<Vec<PurchaseHistoryItem>, SteamUserError> {
311 let document = Html::parse_document(html);
312
313 if let Some(title) = document.select(sel_title()).next() {
315 if title.text().collect::<String>() == "Sign In" {
316 return Err(SteamUserError::Other("Not logged in".into()));
317 }
318 }
319
320 let mut history = Vec::new();
321
322 for row in document.select(sel_wallet_row()) {
323 let date_str = row.select(sel_wht_date()).next().map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
324
325 let date_naive = chrono::NaiveDate::parse_from_str(&date_str, "%d %b, %Y").or_else(|_| chrono::NaiveDate::parse_from_str(&date_str, "%e %b, %Y")).or_else(|_| chrono::NaiveDate::parse_from_str(&date_str, "%b %d, %Y")).or_else(|_| chrono::NaiveDate::parse_from_str(&date_str, "%b %e, %Y")).unwrap_or_default();
330 let date = date_naive.and_hms_opt(0, 0, 0).map(|naive| chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive, chrono::Utc)).unwrap_or_default();
331
332 let transaction_type = row.select(sel_wht_type()).next().map(|el| el.text().find(|t| !t.trim().is_empty()).unwrap_or_default().trim().to_string()).unwrap_or_default();
333
334 let items: Vec<String> = row.select(sel_wht_items()).next().map(|el| el.text().collect::<String>().lines().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()).unwrap_or_default();
336
337 let total = row.select(sel_wht_total()).next().map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
338
339 let payment_method = row.select(sel_wth_payment()).next().map(|el| el.text().collect::<String>().trim().to_string()).filter(|s| !s.is_empty());
340
341 let wallet_balance = row.select(sel_wht_wallet()).next().map(|el| el.text().collect::<String>().trim().to_string()).filter(|s| !s.is_empty());
342
343 let base_price = row.select(sel_wht_base_price()).next().map(|el| el.text().collect::<String>().split_whitespace().collect::<Vec<_>>().join(" ")).filter(|s| !s.is_empty());
344 let tax = row.select(sel_wht_tax()).next().map(|el| el.text().collect::<String>().split_whitespace().collect::<Vec<_>>().join(" ")).filter(|s| !s.is_empty());
345 let shipping = row.select(sel_wht_shipping()).next().map(|el| el.text().collect::<String>().split_whitespace().collect::<Vec<_>>().join(" ")).filter(|s| !s.is_empty());
346 let wallet_change = row.select(sel_wht_wallet_change()).next().map(|el| el.text().collect::<String>().trim().to_string()).filter(|s| !s.is_empty() && s != "Change");
347
348 let mut transaction_id = row.value().attr("data-transid").or_else(|| row.value().attr("data-transactionid")).map(|s| s.to_string());
350
351 if transaction_id.is_none() {
353 if let Some(onclick) = row.value().attr("onclick") {
354 if let Some(caps) = re_transid().captures(onclick) {
355 transaction_id = Some(caps[1].to_string());
356 }
357 }
358 }
359
360 let transaction_id = transaction_id.map(TransactionId);
361
362 if !transaction_type.is_empty() {
364 history.push(PurchaseHistoryItem {
365 date,
366 transaction_type,
367 items,
368 total,
369 base_price,
370 tax,
371 shipping,
372 wallet_change,
373 payment_method,
374 wallet_balance,
375 transaction_id,
376 });
377 }
378 }
379
380 Ok(history)
381 }
382
383 #[steam_endpoint(POST, host = Store, path = "/account/ajaxredeemwalletcode/", kind = Write)]
424 pub async fn redeem_wallet_code(&self, wallet_code: &str) -> Result<RedeemWalletCodeResponse, SteamUserError> {
425 let response: RedeemWalletCodeResponse = self.post_path("/account/ajaxredeemwalletcode/").form(&[("wallet_code", wallet_code)]).send().await?.json().await?;
426
427 Ok(response)
428 }
429
430 #[steam_endpoint(GET, host = Store, path = "/account/authorizeddevices", kind = Read)]
471 pub async fn get_account_details(&self) -> Result<AccountDetails, SteamUserError> {
472 let response = self.get_path("/account/authorizeddevices").send().await?.text().await?;
473
474 let document = Html::parse_document(&response);
475 if let Some(title) = document.select(sel_title()).next() {
476 if title.text().collect::<String>() == "Sign In" {
477 return Err(SteamUserError::Other("Not logged in".into()));
478 }
479 }
480
481 Ok(parse_account_details_html(&response))
482 }
483}
484
485pub fn parse_account_details_html(html: &str) -> AccountDetails {
491 let document = Html::parse_document(html);
492
493 fn parse_json<T: for<'de> serde::Deserialize<'de>>(doc: &Html, attr: &str) -> Option<T> {
494 let sel = Selector::parse(&format!("[{}]", attr)).ok()?;
495 let val = doc.select(&sel).next()?.value().attr(attr)?;
496 serde_json::from_str(val).ok()
497 }
498
499 fn parse_str(doc: &Html, attr: &str) -> Option<String> {
500 let sel = Selector::parse(&format!("[{}]", attr)).ok()?;
501 let val = doc.select(&sel).next()?.value().attr(attr)?;
502 serde_json::from_str(val).ok()
503 }
504
505 let mut page = AccountDetails {
506 active_devices: parse_json::<Vec<_>>(&document, "data-active_devices").unwrap_or_default(),
507 revoked_devices: parse_json::<Vec<_>>(&document, "data-revoked_devices").unwrap_or_default(),
508 two_factor_status: parse_json(&document, "data-two_factor_status"),
509 user_info: parse_json(&document, "data-userinfo"),
510 hw_info: parse_json(&document, "data-hwinfo"),
511 page_config: parse_json(&document, "data-config"),
512 store_user_config: parse_json(&document, "data-store_user_config"),
513 notifications: parse_json(&document, "data-steam_notifications"),
514 broadcast_user: parse_json(&document, "data-broadcastuser"),
515 account_name: parse_str(&document, "data-accountname"),
516 email: parse_str(&document, "data-email"),
517 phone_hint: parse_str(&document, "data-phone_hint"),
518 latest_android_app_version: parse_str(&document, "data-latest_android_app_version"),
519 requesting_token_id: parse_str(&document, "data-requesting_token_id"),
520 wallet_balance: Some(parse_wallet_balance(&document)).filter(|w| w.main_balance.is_some()),
521 ..Default::default()
522 };
523
524 page.avatar_hash = document.select(sel_player_avatar_img()).next().and_then(|el| el.value().attr("src")).and_then(get_avatar_hash_from_url);
525
526 page.country = page.user_info.as_ref().and_then(|u| u.country_code.clone());
527
528 page
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534
535 #[test]
536 fn test_parse_purchase_history() {
537 let html = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/steam_response/get_purchase_history.html")).expect("Failed to read HTML file");
538
539 let mut html_to_parse = String::new();
542 for line in html.lines() {
543 if line.contains("<td class=\"line-content\">") {
544 let text = line.replace("<td class=\"line-content\">", "").replace("</td>", "");
545 html_to_parse.push_str(&text);
546 html_to_parse.push('\n');
547 }
548 }
549
550 if html_to_parse.trim().is_empty() {
551 html_to_parse = html;
552 }
553
554 let html_to_parse = html_to_parse.replace("<span class=\"html-tag\">", "").replace("<span class=\"html-attribute-name\">", "").replace("<span class=\"html-attribute-value\">", "").replace("<a class=\"html-attribute-value html-external-link\"", "<a").replace("</span>", "").replace("<", "<").replace(">", ">").replace(""", "\"").replace("&", "&");
556
557 let result = SteamUser::parse_purchase_history_html(&html_to_parse);
558 assert!(result.is_ok(), "Should parse HTML successfully: {:?}", result.err());
559 let history = result.unwrap();
560
561 assert!(!history.is_empty(), "History should not be empty, html string length: {}", html_to_parse.len());
562
563 let first = &history[0];
566 assert!(!first.transaction_type.is_empty());
569 assert!(!first.total.is_empty());
570
571 tracing::info!("Successfully parsed {} history items", history.len());
572 }
573}