use std::sync::OnceLock;
use scraper::{Html, Selector};
use steamid::SteamID;
static SEL_BLOTTER_DAY: OnceLock<Selector> = OnceLock::new();
fn sel_blotter_day() -> &'static Selector {
SEL_BLOTTER_DAY.get_or_init(|| Selector::parse(".blotter_day").expect("valid CSS selector"))
}
static SEL_BLOTTER_BLOCK: OnceLock<Selector> = OnceLock::new();
fn sel_blotter_block() -> &'static Selector {
SEL_BLOTTER_BLOCK.get_or_init(|| Selector::parse(".blotter_block").expect("valid CSS selector"))
}
static SEL_BLOTTER_DAY_HEADER: OnceLock<Selector> = OnceLock::new();
fn sel_blotter_day_header() -> &'static Selector {
SEL_BLOTTER_DAY_HEADER.get_or_init(|| Selector::parse(".blotter_day_header_date").expect("valid CSS selector"))
}
static SEL_BLOTTER_DAILY_ROLLUP_LINE: OnceLock<Selector> = OnceLock::new();
fn sel_blotter_daily_rollup_line() -> &'static Selector {
SEL_BLOTTER_DAILY_ROLLUP_LINE.get_or_init(|| Selector::parse(".blotter_daily_rollup_line").expect("valid CSS selector"))
}
static SEL_AUTHOR_AVATAR: OnceLock<Selector> = OnceLock::new();
fn sel_author_avatar() -> &'static Selector {
SEL_AUTHOR_AVATAR.get_or_init(|| Selector::parse(".blotter_author_block .playerAvatar img").expect("valid CSS selector"))
}
static SEL_AUTHOR_LINK: OnceLock<Selector> = OnceLock::new();
fn sel_author_link() -> &'static Selector {
SEL_AUTHOR_LINK.get_or_init(|| Selector::parse("a[data-miniprofile]").expect("valid CSS selector"))
}
static SEL_APP_LINKS: OnceLock<Selector> = OnceLock::new();
fn sel_app_links() -> &'static Selector {
SEL_APP_LINKS.get_or_init(|| Selector::parse("a[href*=\"store.steampowered.com/app/\"], a[href*=\"steamcommunity.com/app/\"]").expect("valid CSS selector"))
}
static SEL_IMG_TITLE: OnceLock<Selector> = OnceLock::new();
fn sel_img_title() -> &'static Selector {
SEL_IMG_TITLE.get_or_init(|| Selector::parse("img[title]").expect("valid CSS selector"))
}
static SEL_GROUP_LINKS: OnceLock<Selector> = OnceLock::new();
fn sel_group_links() -> &'static Selector {
SEL_GROUP_LINKS.get_or_init(|| Selector::parse("a[href*=\"steamcommunity.com/groups/\"]").expect("valid CSS selector"))
}
static SEL_COMMENT_THREAD: OnceLock<Selector> = OnceLock::new();
fn sel_comment_thread() -> &'static Selector {
SEL_COMMENT_THREAD.get_or_init(|| Selector::parse(".commentthread_comment").expect("valid CSS selector"))
}
static SEL_COMMENT_AVATAR: OnceLock<Selector> = OnceLock::new();
fn sel_comment_avatar() -> &'static Selector {
SEL_COMMENT_AVATAR.get_or_init(|| Selector::parse(".commentthread_comment_avatar img").expect("valid CSS selector"))
}
static SEL_COMMENT_TIMESTAMP: OnceLock<Selector> = OnceLock::new();
fn sel_comment_timestamp() -> &'static Selector {
SEL_COMMENT_TIMESTAMP.get_or_init(|| Selector::parse(".commentthread_comment_timestamp").expect("valid CSS selector"))
}
use crate::{
client::SteamUser,
endpoint::steam_endpoint,
error::SteamUserError,
types::{ActivityAchievement, ActivityApp, ActivityAuthor, ActivityComment, ActivityCommentResponse, ActivityGroup, ActivityPlayer, ActivityType, FriendActivity, FriendActivityResponse},
utils::avatar::{extract_custom_url, get_avatar_hash_from_url},
};
impl SteamUser {
#[steam_endpoint(GET, host = Community, path = "/profiles/{steam_id}/ajaxgetusernews/", kind = Read)]
pub async fn get_friend_activity(&self, start: Option<u64>) -> Result<FriendActivityResponse, SteamUserError> {
let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
let start_ts = start.unwrap_or_else(|| std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0));
let response: serde_json::Value = self.get_path(format!("/profiles/{}/ajaxgetusernews/?start={}", steam_id.steam_id64(), start_ts)).send().await?.json().await?;
let success = response.get("success").and_then(|v| v.as_bool()).unwrap_or(false);
if !success {
return Ok(FriendActivityResponse::default());
}
let next_request = response.get("next_request").and_then(|v| v.as_str()).map(|s| s.to_string());
let next_request_timestart = next_request.as_ref().and_then(|url| url.split("?start=").last().and_then(|s| s.parse::<u64>().ok()));
let blotter_html = response.get("blotter_html").and_then(|v| v.as_str()).unwrap_or("").to_string();
let activities = tokio::task::spawn_blocking(move || parse_activity_feed(&blotter_html)).await.map_err(|e| crate::error::SteamUserError::Other(format!("activity-feed parse task failed: {e}")))?;
Ok(FriendActivityResponse { activities, next_request_timestart, next_request_url: next_request })
}
#[tracing::instrument(skip(self))]
pub async fn get_friend_activity_full(&self) -> Result<Vec<FriendActivity>, SteamUserError> {
let mut all_activities = Vec::new();
let mut next_start: Option<u64> = None;
loop {
let response = self.get_friend_activity(next_start).await?;
all_activities.extend(response.activities);
match response.next_request_timestart {
Some(ts) => next_start = Some(ts),
None => break,
}
}
Ok(all_activities)
}
#[steam_endpoint(POST, host = Community, path = "/comment/UserReceivedNewGame/post/{steam_id}/{thread_id}/", kind = Write)]
pub async fn comment_user_received_new_game(&self, steam_id: SteamID, thread_id: u64, comment: &str) -> Result<ActivityCommentResponse, SteamUserError> {
let form = [("comment", comment), ("count", "3"), ("feature2", "-1"), ("newestfirstpagination", "true")];
let response: serde_json::Value = self.post_path(format!("/comment/UserReceivedNewGame/post/{}/{}/", steam_id.steam_id64(), thread_id)).form(&form).send().await?.json().await?;
Ok(parse_comment_response(&response))
}
#[steam_endpoint(POST, host = Community, path = "/comment/UserReceivedNewGame/voteup/{steam_id}/{thread_id}/", kind = Write)]
pub async fn rate_up_user_received_new_game(&self, steam_id: SteamID, thread_id: u64) -> Result<ActivityCommentResponse, SteamUserError> {
let form = [("vote", "1"), ("count", "3"), ("feature2", "-1"), ("newestfirstpagination", "true")];
let response: serde_json::Value = self.post_path(format!("/comment/UserReceivedNewGame/voteup/{}/{}/", steam_id.steam_id64(), thread_id)).form(&form).send().await?.json().await?;
Ok(parse_comment_response(&response))
}
#[steam_endpoint(POST, host = Community, path = "/comment/UserReceivedNewGame/delete/{steam_id}/{thread_id}/", kind = Write)]
pub async fn delete_comment_user_received_new_game(&self, steam_id: SteamID, thread_id: u64, comment_id: &str) -> Result<ActivityCommentResponse, SteamUserError> {
let form = [("gidcomment", comment_id), ("start", "0"), ("count", "3"), ("feature2", "-1"), ("newestfirstpagination", "true")];
let response: serde_json::Value = self.post_path(format!("/comment/UserReceivedNewGame/delete/{}/{}/", steam_id.steam_id64(), thread_id)).form(&form).send().await?.json().await?;
Ok(parse_comment_response(&response))
}
}
fn parse_activity_feed(html: &str) -> Vec<FriendActivity> {
let cleaned_html = html.replace(['\t', '\n', '\r'], "");
let document = Html::parse_document(&format!("<div>{}</div>", cleaned_html));
let mut activities = Vec::new();
for day_element in document.select(sel_blotter_day()) {
let timestamp = day_element.value().attr("id").and_then(|id| id.strip_prefix("blotter_day_")).and_then(|ts| ts.parse::<u64>().ok()).unwrap_or(0);
let header_date = day_element.select(sel_blotter_day_header()).next().map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
for block_element in day_element.select(sel_blotter_block()) {
let block_html = block_element.html();
let block_doc = Html::parse_fragment(&block_html);
let activity_type = determine_activity_type(&block_doc);
let mut activity = match activity_type {
ActivityType::DailyRollup => parse_blotter_daily_rollup(&block_doc),
ActivityType::GamePurchase => parse_blotter_game_purchase(&block_doc),
_ => FriendActivity { activity_type: activity_type.clone(), ..Default::default() },
};
activity.timestamp = timestamp;
activity.header_date = header_date.clone();
activities.push(activity);
}
}
activities
}
fn determine_activity_type(doc: &Html) -> ActivityType {
let html = doc.html();
if html.contains("blotter_daily_rollup") {
ActivityType::DailyRollup
} else if html.contains("blotter_gamepurchase") {
ActivityType::GamePurchase
} else if html.contains("blotter_workshopitempublished") {
ActivityType::WorkshopItemPublished
} else if html.contains("blotter_recommendation") {
ActivityType::Recommendation
} else if html.contains("blotter_userstatus") {
ActivityType::UserStatus
} else if html.contains("blotter_screenshot") {
ActivityType::Screenshot
} else if html.contains("blotter_videopublished") {
ActivityType::VideoPublished
} else {
ActivityType::Unknown("unknown".to_string())
}
}
fn parse_blotter_daily_rollup(doc: &Html) -> FriendActivity {
let mut activity = FriendActivity { activity_type: ActivityType::DailyRollup, ..Default::default() };
for line_element in doc.select(sel_blotter_daily_rollup_line()) {
let line_html = line_element.html();
let line_doc = Html::parse_fragment(&line_html);
let content_text = line_element.text().collect::<String>();
let players = parse_player_list_from_blotter(&line_doc);
let apps = parse_app_list_from_blotter(&line_doc);
let achieved = parse_achieved_from_blotter(&line_doc);
let groups = parse_group_list_from_blotter(&line_doc);
activity.players.extend(players);
activity.apps.extend(apps);
activity.achieved.extend(achieved);
activity.groups.extend(groups);
if content_text.contains("are now friends") || content_text.contains("is now friends with") {
activity.activity_type = ActivityType::NewFriend;
} else if content_text.contains("played") && content_text.contains("for the first time") {
activity.activity_type = ActivityType::PlayedFirstTime;
} else if content_text.contains("achieved") {
activity.activity_type = ActivityType::Achieved;
} else if content_text.contains("has added") && content_text.contains("to their wishlist") {
activity.activity_type = ActivityType::AddedToWishlist;
} else if content_text.contains("is now following") {
activity.activity_type = ActivityType::Following;
} else if content_text.contains("has joined") {
activity.activity_type = ActivityType::Joined;
}
}
activity
}
fn parse_blotter_game_purchase(doc: &Html) -> FriendActivity {
let mut activity = FriendActivity { activity_type: ActivityType::GamePurchase, ..Default::default() };
if let Some(avatar_el) = doc.select(sel_author_avatar()).next() {
let avatar_src = avatar_el.value().attr("src").unwrap_or("");
let avatar_hash = get_avatar_hash_from_url(avatar_src).unwrap_or_default();
if let Some(author_el) = doc.select(sel_author_link()).next() {
let miniprofile = author_el.value().attr("data-miniprofile").and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
let profile_url = author_el.value().attr("href").unwrap_or("").to_string();
let custom_url = extract_custom_url(&profile_url);
let name = author_el.text().collect::<String>().trim().to_string();
activity.author = Some(ActivityAuthor {
name,
nickname: None,
avatar_hash,
miniprofile,
steam_id: SteamID::from_individual_account_id(u32::try_from(miniprofile).unwrap_or(0)),
profile_url,
custom_url,
});
}
}
activity.apps = parse_app_list_from_blotter(doc);
activity.thread_id = parse_thread_id(doc);
activity.comments = parse_activity_comments(doc);
activity
}
fn parse_player_list_from_blotter(doc: &Html) -> Vec<ActivityPlayer> {
let mut players = Vec::new();
for element in doc.select(sel_author_link()) {
let miniprofile = element.value().attr("data-miniprofile").and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
if miniprofile == 0 {
continue;
}
let name = element.text().collect::<String>().trim().to_string();
players.push(ActivityPlayer { name, nickname: None, miniprofile, steam_id: SteamID::from_individual_account_id(u32::try_from(miniprofile).unwrap_or(0)) });
}
players
}
fn parse_app_list_from_blotter(doc: &Html) -> Vec<ActivityApp> {
let mut apps = Vec::new();
for element in doc.select(sel_app_links()) {
let link = element.value().attr("href").unwrap_or("").to_string();
let name = element.text().collect::<String>().trim().to_string();
let id = parse_app_id_from_link(&link);
if id > 0 {
apps.push(ActivityApp { id, name, link });
}
}
apps.sort_by_key(|a| a.id);
apps.dedup_by_key(|a| a.id);
apps
}
fn parse_achieved_from_blotter(doc: &Html) -> Vec<ActivityAchievement> {
let mut achieved = Vec::new();
for element in doc.select(sel_img_title()) {
let title = element.value().attr("title").unwrap_or("").to_string();
let img = element.value().attr("src").unwrap_or("").to_string();
if !title.is_empty() && img.contains("achievement") {
achieved.push(ActivityAchievement { title, img });
}
}
achieved
}
fn parse_group_list_from_blotter(doc: &Html) -> Vec<ActivityGroup> {
let mut groups = Vec::new();
for element in doc.select(sel_group_links()) {
let link = element.value().attr("href").unwrap_or("").to_string();
let name = element.text().collect::<String>().trim().to_string();
let url = link.split("steamcommunity.com/groups/").nth(1).unwrap_or("").trim_end_matches('/').to_string();
if !url.is_empty() {
groups.push(ActivityGroup { name, link, url });
}
}
groups
}
fn parse_thread_id(doc: &Html) -> Option<u64> {
let html = doc.html();
if let Some(start) = html.find("UserReceivedNewGame_") {
let rest = &html[start..];
if let Some(end) = rest.find('\'') {
let id_part = &rest[..end];
if let Some(last_underscore) = id_part.rfind('_') {
if let Ok(id) = id_part[last_underscore + 1..].parse::<u64>() {
return Some(id);
}
}
}
}
if let Some(start) = html.find("commentthread_UserReceivedNewGame_") {
let rest = &html[start..];
let parts: Vec<&str> = rest.split('_').collect();
if parts.len() >= 3 {
if let Ok(id) = parts[2].parse::<u64>() {
return Some(id);
}
}
}
None
}
fn parse_activity_comments(doc: &Html) -> Vec<ActivityComment> {
let mut comments = Vec::new();
for element in doc.select(sel_comment_thread()) {
let id = element.value().attr("id").unwrap_or("").replace("comment_", "");
if id.is_empty() {
continue;
}
let author_avatar_hash = element.select(sel_comment_avatar()).next().and_then(|el| el.value().attr("src")).and_then(get_avatar_hash_from_url).unwrap_or_default();
let author_miniprofile = element.select(sel_author_link()).next().and_then(|el| el.value().attr("data-miniprofile")).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
let timestamp = element.select(sel_comment_timestamp()).next().and_then(|el| el.value().attr("data-timestamp")).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
comments.push(ActivityComment {
id,
author_steam_id: SteamID::from_individual_account_id(u32::try_from(author_miniprofile).unwrap_or(0)),
author_miniprofile,
author_avatar_hash,
timestamp,
});
}
comments
}
fn parse_app_id_from_link(link: &str) -> u32 {
let prefixes = ["steamcommunity.com/app/", "store.steampowered.com/app/", "store.steampowered.com/sub/"];
for prefix in prefixes {
if let Some(start) = link.find(prefix) {
let rest = &link[start + prefix.len()..];
let id_str = rest.split('/').next().unwrap_or("");
if let Ok(id) = id_str.parse::<u32>() {
return id;
}
}
}
0
}
fn parse_comment_response(response: &serde_json::Value) -> ActivityCommentResponse {
let success = response.get("success").and_then(|v| v.as_bool()).or_else(|| response.get("success").and_then(|v| v.as_i64()).map(|n| n == 1)).unwrap_or(false);
let total_count = response.get("total_count").and_then(|v| v.as_u64()).map(|n| u32::try_from(n).unwrap_or(u32::MAX)).unwrap_or(0);
let upvotes = response.get("upvotes").and_then(|v| v.as_u64()).map(|n| u32::try_from(n).unwrap_or(u32::MAX)).unwrap_or(0);
let has_upvoted = response.get("has_upvoted").and_then(|v| v.as_i64()).map(|n| n == 1).unwrap_or(false);
ActivityCommentResponse { success, total_count, upvotes, has_upvoted }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_app_id_from_link() {
assert_eq!(parse_app_id_from_link("https://store.steampowered.com/app/730/Counter-Strike_2/"), 730);
assert_eq!(parse_app_id_from_link("https://steamcommunity.com/app/570"), 570);
assert_eq!(parse_app_id_from_link("https://store.steampowered.com/sub/469"), 469);
assert_eq!(parse_app_id_from_link("https://example.com"), 0);
}
#[test]
fn test_determine_activity_type() {
let doc = Html::parse_fragment("<div class=\"blotter_gamepurchase\">test</div>");
assert_eq!(determine_activity_type(&doc), ActivityType::GamePurchase);
let doc = Html::parse_fragment("<div class=\"blotter_daily_rollup\">test</div>");
assert_eq!(determine_activity_type(&doc), ActivityType::DailyRollup);
}
}