use std::sync::OnceLock;
use regex::Regex;
use scraper::{Html, Selector};
use crate::{
client::SteamUser,
endpoint::steam_endpoint,
error::SteamUserError,
types::{AccountDetails, PurchaseHistoryItem, RedeemWalletCodeResponse, TransactionId, WalletBalance},
utils::get_avatar_hash_from_url,
};
static SEL_BALANCE: OnceLock<Selector> = OnceLock::new();
fn sel_balance() -> &'static Selector {
SEL_BALANCE.get_or_init(|| Selector::parse("#header_wallet_balance").expect("valid CSS selector"))
}
static SEL_TOOLTIP: OnceLock<Selector> = OnceLock::new();
fn sel_tooltip() -> &'static Selector {
SEL_TOOLTIP.get_or_init(|| Selector::parse("span.tooltip").expect("valid CSS selector"))
}
static SEL_HELP_SPEND: OnceLock<Selector> = OnceLock::new();
fn sel_help_spend() -> &'static Selector {
SEL_HELP_SPEND.get_or_init(|| Selector::parse(".help_event_limiteduser .help_event_limiteduser_spend").expect("valid CSS selector"))
}
static SEL_TITLE: OnceLock<Selector> = OnceLock::new();
fn sel_title() -> &'static Selector {
SEL_TITLE.get_or_init(|| Selector::parse("title").expect("valid CSS selector"))
}
static SEL_WALLET_ROW: OnceLock<Selector> = OnceLock::new();
fn sel_wallet_row() -> &'static Selector {
SEL_WALLET_ROW.get_or_init(|| Selector::parse(".wallet_table_row").expect("valid CSS selector"))
}
static SEL_WHT_DATE: OnceLock<Selector> = OnceLock::new();
fn sel_wht_date() -> &'static Selector {
SEL_WHT_DATE.get_or_init(|| Selector::parse(".wht_date").expect("valid CSS selector"))
}
static SEL_WHT_TYPE: OnceLock<Selector> = OnceLock::new();
fn sel_wht_type() -> &'static Selector {
SEL_WHT_TYPE.get_or_init(|| Selector::parse(".wht_type").expect("valid CSS selector"))
}
static SEL_WHT_ITEMS: OnceLock<Selector> = OnceLock::new();
fn sel_wht_items() -> &'static Selector {
SEL_WHT_ITEMS.get_or_init(|| Selector::parse(".wht_items").expect("valid CSS selector"))
}
static SEL_WHT_TOTAL: OnceLock<Selector> = OnceLock::new();
fn sel_wht_total() -> &'static Selector {
SEL_WHT_TOTAL.get_or_init(|| Selector::parse(".wht_total").expect("valid CSS selector"))
}
static SEL_WHT_BASE_PRICE: OnceLock<Selector> = OnceLock::new();
fn sel_wht_base_price() -> &'static Selector {
SEL_WHT_BASE_PRICE.get_or_init(|| Selector::parse(".wht_base_price, .wht_base_price_discounted").expect("valid CSS selector"))
}
static SEL_WHT_TAX: OnceLock<Selector> = OnceLock::new();
fn sel_wht_tax() -> &'static Selector {
SEL_WHT_TAX.get_or_init(|| Selector::parse(".wht_tax").expect("valid CSS selector"))
}
static SEL_WHT_SHIPPING: OnceLock<Selector> = OnceLock::new();
fn sel_wht_shipping() -> &'static Selector {
SEL_WHT_SHIPPING.get_or_init(|| Selector::parse(".wht_shipping").expect("valid CSS selector"))
}
static SEL_WHT_WALLET_CHANGE: OnceLock<Selector> = OnceLock::new();
fn sel_wht_wallet_change() -> &'static Selector {
SEL_WHT_WALLET_CHANGE.get_or_init(|| Selector::parse(".wht_wallet_change").expect("valid CSS selector"))
}
static SEL_WHT_WALLET: OnceLock<Selector> = OnceLock::new();
fn sel_wht_wallet() -> &'static Selector {
SEL_WHT_WALLET.get_or_init(|| Selector::parse(".wht_wallet_balance").expect("valid CSS selector"))
}
static SEL_WTH_PAYMENT: OnceLock<Selector> = OnceLock::new();
fn sel_wth_payment() -> &'static Selector {
SEL_WTH_PAYMENT.get_or_init(|| Selector::parse(".wth_payment").expect("valid CSS selector"))
}
static SEL_PLAYER_AVATAR_IMG: OnceLock<Selector> = OnceLock::new();
fn sel_player_avatar_img() -> &'static Selector {
SEL_PLAYER_AVATAR_IMG.get_or_init(|| Selector::parse(".playerAvatar img").expect("valid CSS selector"))
}
static RE_CURRENCY_END: OnceLock<Regex> = OnceLock::new();
fn re_currency_end() -> &'static Regex {
RE_CURRENCY_END.get_or_init(|| Regex::new(r"([^\d.,\s].*)$").expect("valid regex"))
}
static RE_CURRENCY_START: OnceLock<Regex> = OnceLock::new();
fn re_currency_start() -> &'static Regex {
RE_CURRENCY_START.get_or_init(|| Regex::new(r"^([^\d.,\s]+)").expect("valid regex"))
}
static RE_PENDING: OnceLock<Regex> = OnceLock::new();
fn re_pending() -> &'static Regex {
RE_PENDING.get_or_init(|| Regex::new(r"Pending:\s*([\d.,]+[^\s]*)").expect("valid regex"))
}
static RE_TRANSID: OnceLock<Regex> = OnceLock::new();
fn re_transid() -> &'static Regex {
RE_TRANSID.get_or_init(|| Regex::new(r"transid=(\d+)").expect("valid regex"))
}
pub(crate) fn parse_wallet_balance(document: &Html) -> WalletBalance {
let mut main_balance = None;
let mut currency = None;
let mut pending = None;
if let Some(el) = document.select(sel_balance()).next() {
let text: String = el.children().filter_map(|n| n.value().as_text().map(|t| t.to_string())).collect::<String>().trim().to_string();
if !text.is_empty() {
if let Some(caps) = re_currency_end().captures(&text) {
currency = Some(caps[1].trim().to_string());
} else if let Some(caps) = re_currency_start().captures(&text) {
currency = Some(caps[1].trim().to_string());
}
main_balance = Some(text);
}
if let Some(tip) = el.select(sel_tooltip()).next() {
let tip_text = tip.text().collect::<String>();
if let Some(caps) = re_pending().captures(&tip_text) {
pending = Some(caps[1].to_string());
}
}
}
WalletBalance { main_balance, pending, currency }
}
impl SteamUser {
#[tracing::instrument(skip(self))]
pub async fn get_steam_wallet_balance(&self) -> Result<WalletBalance, SteamUserError> {
let details = self.get_account_details().await?;
details.wallet_balance.ok_or_else(|| SteamUserError::Other("Wallet balance not found".into()))
}
#[steam_endpoint(GET, host = Help, path = "/en/", kind = Read)]
pub async fn get_amount_spent_on_steam(&self) -> Result<String, SteamUserError> {
let response = self.get_path("/en/").send().await?.text().await?;
let document = Html::parse_document(&response);
if let Some(el) = document.select(sel_help_spend()).next() {
let text = el.text().collect::<String>().trim().to_string();
let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
if text.starts_with("Amount Spent on Steam:") {
return Ok(text.replace("Amount Spent on Steam:", "").trim().to_string());
}
}
Err(SteamUserError::Other("Amount spent information not found on help page".into()))
}
#[steam_endpoint(POST, host = Community, path = "/parental/ajaxunlock", kind = Auth)]
pub async fn parental_unlock(&self, pin: &str) -> Result<(), SteamUserError> {
let response: serde_json::Value = self.post_path("/parental/ajaxunlock").form(&[("pin", pin)]).send().await?.json().await?;
let result = Self::check_json_success(&response, "Failed to unlock parental controls");
match result {
Ok(_) => Ok(()),
Err(SteamUserError::EResult { code, .. }) => match code {
15 => Err(SteamUserError::Other("Incorrect PIN".into())),
25 => Err(SteamUserError::Other("Too many invalid PIN attempts".into())),
_ => Err(SteamUserError::from_eresult(code)),
},
Err(e) => Err(e),
}
}
#[steam_endpoint(GET, host = Store, path = "/account/history/", kind = Read)]
pub async fn get_purchase_history(&self) -> Result<Vec<PurchaseHistoryItem>, SteamUserError> {
let response = self.get_path("/account/history/").send().await?.text().await?;
Self::parse_purchase_history_html(&response)
}
pub fn parse_purchase_history_html(html: &str) -> Result<Vec<PurchaseHistoryItem>, SteamUserError> {
let document = Html::parse_document(html);
if let Some(title) = document.select(sel_title()).next() {
if title.text().collect::<String>() == "Sign In" {
return Err(SteamUserError::Other("Not logged in".into()));
}
}
let mut history = Vec::new();
for row in document.select(sel_wallet_row()) {
let date_str = row.select(sel_wht_date()).next().map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
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();
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();
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();
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();
let total = row.select(sel_wht_total()).next().map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
let payment_method = row.select(sel_wth_payment()).next().map(|el| el.text().collect::<String>().trim().to_string()).filter(|s| !s.is_empty());
let wallet_balance = row.select(sel_wht_wallet()).next().map(|el| el.text().collect::<String>().trim().to_string()).filter(|s| !s.is_empty());
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());
let tax = row.select(sel_wht_tax()).next().map(|el| el.text().collect::<String>().split_whitespace().collect::<Vec<_>>().join(" ")).filter(|s| !s.is_empty());
let shipping = row.select(sel_wht_shipping()).next().map(|el| el.text().collect::<String>().split_whitespace().collect::<Vec<_>>().join(" ")).filter(|s| !s.is_empty());
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");
let mut transaction_id = row.value().attr("data-transid").or_else(|| row.value().attr("data-transactionid")).map(|s| s.to_string());
if transaction_id.is_none() {
if let Some(onclick) = row.value().attr("onclick") {
if let Some(caps) = re_transid().captures(onclick) {
transaction_id = Some(caps[1].to_string());
}
}
}
let transaction_id = transaction_id.map(TransactionId);
if !transaction_type.is_empty() {
history.push(PurchaseHistoryItem {
date,
transaction_type,
items,
total,
base_price,
tax,
shipping,
wallet_change,
payment_method,
wallet_balance,
transaction_id,
});
}
}
Ok(history)
}
#[steam_endpoint(POST, host = Store, path = "/account/ajaxredeemwalletcode/", kind = Write)]
pub async fn redeem_wallet_code(&self, wallet_code: &str) -> Result<RedeemWalletCodeResponse, SteamUserError> {
let response: RedeemWalletCodeResponse = self.post_path("/account/ajaxredeemwalletcode/").form(&[("wallet_code", wallet_code)]).send().await?.json().await?;
Ok(response)
}
#[steam_endpoint(GET, host = Store, path = "/account/authorizeddevices", kind = Read)]
pub async fn get_account_details(&self) -> Result<AccountDetails, SteamUserError> {
let response = self.get_path("/account/authorizeddevices").send().await?.text().await?;
let document = Html::parse_document(&response);
if let Some(title) = document.select(sel_title()).next() {
if title.text().collect::<String>() == "Sign In" {
return Err(SteamUserError::Other("Not logged in".into()));
}
}
Ok(parse_account_details_html(&response))
}
}
pub fn parse_account_details_html(html: &str) -> AccountDetails {
let document = Html::parse_document(html);
fn parse_json<T: for<'de> serde::Deserialize<'de>>(doc: &Html, attr: &str) -> Option<T> {
let sel = Selector::parse(&format!("[{}]", attr)).ok()?;
let val = doc.select(&sel).next()?.value().attr(attr)?;
serde_json::from_str(val).ok()
}
fn parse_str(doc: &Html, attr: &str) -> Option<String> {
let sel = Selector::parse(&format!("[{}]", attr)).ok()?;
let val = doc.select(&sel).next()?.value().attr(attr)?;
serde_json::from_str(val).ok()
}
let mut page = AccountDetails {
active_devices: parse_json::<Vec<_>>(&document, "data-active_devices").unwrap_or_default(),
revoked_devices: parse_json::<Vec<_>>(&document, "data-revoked_devices").unwrap_or_default(),
two_factor_status: parse_json(&document, "data-two_factor_status"),
user_info: parse_json(&document, "data-userinfo"),
hw_info: parse_json(&document, "data-hwinfo"),
page_config: parse_json(&document, "data-config"),
store_user_config: parse_json(&document, "data-store_user_config"),
notifications: parse_json(&document, "data-steam_notifications"),
broadcast_user: parse_json(&document, "data-broadcastuser"),
account_name: parse_str(&document, "data-accountname"),
email: parse_str(&document, "data-email"),
phone_hint: parse_str(&document, "data-phone_hint"),
latest_android_app_version: parse_str(&document, "data-latest_android_app_version"),
requesting_token_id: parse_str(&document, "data-requesting_token_id"),
wallet_balance: Some(parse_wallet_balance(&document)).filter(|w| w.main_balance.is_some()),
..Default::default()
};
page.avatar_hash = document.select(sel_player_avatar_img()).next().and_then(|el| el.value().attr("src")).and_then(get_avatar_hash_from_url);
page.country = page.user_info.as_ref().and_then(|u| u.country_code.clone());
page
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_purchase_history() {
let html = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/steam_response/get_purchase_history.html")).expect("Failed to read HTML file");
let mut html_to_parse = String::new();
for line in html.lines() {
if line.contains("<td class=\"line-content\">") {
let text = line.replace("<td class=\"line-content\">", "").replace("</td>", "");
html_to_parse.push_str(&text);
html_to_parse.push('\n');
}
}
if html_to_parse.trim().is_empty() {
html_to_parse = html;
}
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("&", "&");
let result = SteamUser::parse_purchase_history_html(&html_to_parse);
assert!(result.is_ok(), "Should parse HTML successfully: {:?}", result.err());
let history = result.unwrap();
assert!(!history.is_empty(), "History should not be empty, html string length: {}", html_to_parse.len());
let first = &history[0];
assert!(!first.transaction_type.is_empty());
assert!(!first.total.is_empty());
tracing::info!("Successfully parsed {} history items", history.len());
}
}