use scraper::{Html, Selector};
use steamid::SteamID;
use crate::{
client::SteamUser,
endpoint::steam_endpoint,
error::SteamUserError,
types::{OnlineState, PrivacyState, PublicProfileSummary, TradeBanState},
};
impl SteamUser {
#[steam_endpoint(GET, host = Community, path = "/dev/apikey", kind = Read)]
pub async fn get_web_api_key(&self, domain: &str) -> Result<String, SteamUserError> {
let response = self.get_path("/dev/apikey").send().await?;
self.check_response(&response)?;
let html = response.text().await?;
if html.contains("You will be granted access to Steam Web API keys when you have games in your Steam account.") {
return Err(SteamUserError::LimitedAccount("Account needs games to register API key".into()));
}
let key = parse_web_api_key(&html);
if let Some(k) = key {
return Ok(k);
}
let document = Html::parse_document(&html);
let form_selector = Selector::parse("form[action*='/dev/registerkey']").map_err(|_| SteamUserError::Other("Failed to parse selector".into()))?;
if document.select(&form_selector).next().is_some() {
let response = self.post_path("/dev/registerkey").form(&[("domain", domain), ("agreeToTerms", "agreed"), ("Submit", "Register")]).send().await?;
self.check_response(&response)?;
let html = response.text().await?;
if let Some(k) = parse_web_api_key(&html) {
return Ok(k);
}
return Err(SteamUserError::SteamError("Failed to register API key".into()));
}
Err(SteamUserError::SteamError("Failed to retrieve or register API key".into()))
}
#[steam_endpoint(POST, host = Community, path = "/dev/revokekey", kind = Write)]
pub async fn revoke_web_api_key(&self) -> Result<(), SteamUserError> {
let response = self.post_path("/dev/revokekey").form(&[("Revoke", "Revoke My Steam Web API Key")]).send().await?;
self.check_response(&response)?;
let html = response.text().await?;
if parse_web_api_key(&html).is_none() {
Ok(())
} else {
Err(SteamUserError::SteamError("Failed to revoke API key".into()))
}
}
#[steam_endpoint(GET, host = Api, path = "/ISteamUser/ResolveVanityURL/v0001/", kind = Read)]
pub async fn resolve_vanity_url(&self, api_key: &str, vanity_url_name: &str) -> Result<steamid::SteamID, SteamUserError> {
let params = [("key", api_key), ("vanityurl", vanity_url_name)];
let response = self.get_path("/ISteamUser/ResolveVanityURL/v0001/").query(¶ms).send().await?;
self.check_response(&response)?;
let text = response.text().await?;
let json: ResolveVanityUrlResponse = serde_json::from_str(&text).map_err(|e| SteamUserError::Other(format!("Failed to parse ResolveVanityURL response: {}", e)))?;
if json.response.success == 1 {
if let Some(steamid_str) = json.response.steamid {
return steamid_str.parse::<steamid::SteamID>().map_err(|e| SteamUserError::Other(format!("Failed to parse SteamID from response: {}", e)));
}
}
Err(SteamUserError::Other(json.response.message.unwrap_or_else(|| "Failed to resolve vanity URL".to_string())))
}
#[steam_endpoint(GET, host = Community, path = "/id/{vanity}/", kind = Read)]
pub async fn resolve_vanity_url_public(&self, vanity: &str) -> Result<PublicProfileSummary, SteamUserError> {
if vanity.is_empty() || !vanity.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
return Err(SteamUserError::InvalidInput(format!("invalid vanity slug: {vanity:?}")));
}
let path = format!("/id/{vanity}/?xml=1");
self.fetch_public_profile_xml(&path, vanity).await
}
#[steam_endpoint(GET, host = Community, path = "/profiles/{steam_id}/", kind = Read)]
pub async fn fetch_public_profile_by_id(&self, steam_id: SteamID) -> Result<PublicProfileSummary, SteamUserError> {
let id_str = steam_id.to_string();
let path = format!("/profiles/{id_str}/?xml=1");
self.fetch_public_profile_xml(&path, &id_str).await
}
#[tracing::instrument(skip(self), fields(path = %path, identifier = %identifier))]
async fn fetch_public_profile_xml(&self, path: &str, identifier: &str) -> Result<PublicProfileSummary, SteamUserError> {
let response = self.get_path(path).send().await?;
self.check_response(&response)?;
let body = response.text().await?;
parse_public_profile_xml(&body).ok_or_else(|| SteamUserError::Other(format!("profile not found: {identifier}")))
}
}
fn extract_tag<'a>(body: &'a str, tag: &str) -> Option<&'a str> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = body.find(&open)? + open.len();
let end = body[start..].find(&close)?;
let inner = body[start..start + end].trim();
inner.strip_prefix("<![CDATA[").and_then(|s| s.strip_suffix("]]>")).map(str::trim).or(Some(inner))
}
fn extract_tag_opt<'a>(body: &'a str, tag: &str) -> Option<&'a str> {
extract_tag(body, tag).filter(|s| !s.is_empty())
}
fn parse_public_profile_xml(body: &str) -> Option<PublicProfileSummary> {
let steam_id = extract_tag(body, "steamID64")?.parse::<SteamID>().ok()?;
let privacy_state = extract_tag(body, "visibilityState").and_then(|s| s.parse::<i32>().ok()).map_or(PrivacyState::default(), PrivacyState::from);
Some(PublicProfileSummary {
steam_id,
persona_name: extract_tag(body, "steamID").unwrap_or("").to_owned(),
online_state: OnlineState::from_xml(extract_tag(body, "onlineState").unwrap_or("")),
state_message: extract_tag(body, "stateMessage").unwrap_or("").to_owned(),
privacy_state,
avatar_icon: extract_tag(body, "avatarIcon").unwrap_or("").to_owned(),
avatar_medium: extract_tag(body, "avatarMedium").unwrap_or("").to_owned(),
avatar_full: extract_tag(body, "avatarFull").unwrap_or("").to_owned(),
vac_banned: extract_tag(body, "vacBanned").is_some_and(|s| s == "1"),
trade_ban_state: TradeBanState::from_xml(extract_tag(body, "tradeBanState").unwrap_or("None")),
is_limited_account: extract_tag(body, "isLimitedAccount").is_some_and(|s| s == "1"),
custom_url: extract_tag_opt(body, "customURL").map(str::to_owned),
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)),
headline: extract_tag_opt(body, "headline").map(str::to_owned),
location: extract_tag_opt(body, "location").map(str::to_owned),
real_name: extract_tag_opt(body, "realname").map(str::to_owned),
summary: extract_tag_opt(body, "summary").map(str::to_owned),
hours_played_2wk: extract_tag_opt(body, "hoursPlayed2Wk").and_then(|s| s.parse().ok()),
})
}
fn parse_web_api_key(html: &str) -> Option<String> {
if html.contains("Revoke My Steam Web API Key") {
let fragment = Html::parse_fragment(html);
let p_selector = Selector::parse("#bodyContents_ex > p").ok()?;
for element in fragment.select(&p_selector) {
let text = element.text().collect::<String>();
if text.contains("Key:") {
let parts: Vec<&str> = text.split("Key:").collect();
if parts.len() > 1 {
return Some(parts[1].trim().to_string());
}
}
}
}
None
}
#[derive(serde::Deserialize)]
struct ResolveVanityUrlResponse {
response: ResolveVanityUrlResponseInner,
}
#[derive(serde::Deserialize)]
struct ResolveVanityUrlResponseInner {
steamid: Option<String>,
success: i32,
message: Option<String>,
}