Skip to main content

steam_user/services/
activity.rs

1//! Friend Activity Feed services.
2//!
3//! This module provides functionality for fetching and interacting with
4//! the Steam friend activity feed, including game purchases, achievements,
5//! wishlist additions, and more.
6
7use std::sync::OnceLock;
8
9use scraper::{Html, Selector};
10use steamid::SteamID;
11
12static SEL_BLOTTER_DAY: OnceLock<Selector> = OnceLock::new();
13fn sel_blotter_day() -> &'static Selector {
14    SEL_BLOTTER_DAY.get_or_init(|| Selector::parse(".blotter_day").expect("valid CSS selector"))
15}
16
17static SEL_BLOTTER_BLOCK: OnceLock<Selector> = OnceLock::new();
18fn sel_blotter_block() -> &'static Selector {
19    SEL_BLOTTER_BLOCK.get_or_init(|| Selector::parse(".blotter_block").expect("valid CSS selector"))
20}
21
22static SEL_BLOTTER_DAY_HEADER: OnceLock<Selector> = OnceLock::new();
23fn sel_blotter_day_header() -> &'static Selector {
24    SEL_BLOTTER_DAY_HEADER.get_or_init(|| Selector::parse(".blotter_day_header_date").expect("valid CSS selector"))
25}
26
27static SEL_BLOTTER_DAILY_ROLLUP_LINE: OnceLock<Selector> = OnceLock::new();
28fn sel_blotter_daily_rollup_line() -> &'static Selector {
29    SEL_BLOTTER_DAILY_ROLLUP_LINE.get_or_init(|| Selector::parse(".blotter_daily_rollup_line").expect("valid CSS selector"))
30}
31
32static SEL_AUTHOR_AVATAR: OnceLock<Selector> = OnceLock::new();
33fn sel_author_avatar() -> &'static Selector {
34    SEL_AUTHOR_AVATAR.get_or_init(|| Selector::parse(".blotter_author_block .playerAvatar img").expect("valid CSS selector"))
35}
36
37static SEL_AUTHOR_LINK: OnceLock<Selector> = OnceLock::new();
38fn sel_author_link() -> &'static Selector {
39    SEL_AUTHOR_LINK.get_or_init(|| Selector::parse("a[data-miniprofile]").expect("valid CSS selector"))
40}
41
42static SEL_APP_LINKS: OnceLock<Selector> = OnceLock::new();
43fn sel_app_links() -> &'static Selector {
44    SEL_APP_LINKS.get_or_init(|| Selector::parse("a[href*=\"store.steampowered.com/app/\"], a[href*=\"steamcommunity.com/app/\"]").expect("valid CSS selector"))
45}
46
47static SEL_IMG_TITLE: OnceLock<Selector> = OnceLock::new();
48fn sel_img_title() -> &'static Selector {
49    SEL_IMG_TITLE.get_or_init(|| Selector::parse("img[title]").expect("valid CSS selector"))
50}
51
52static SEL_GROUP_LINKS: OnceLock<Selector> = OnceLock::new();
53fn sel_group_links() -> &'static Selector {
54    SEL_GROUP_LINKS.get_or_init(|| Selector::parse("a[href*=\"steamcommunity.com/groups/\"]").expect("valid CSS selector"))
55}
56
57static SEL_COMMENT_THREAD: OnceLock<Selector> = OnceLock::new();
58fn sel_comment_thread() -> &'static Selector {
59    SEL_COMMENT_THREAD.get_or_init(|| Selector::parse(".commentthread_comment").expect("valid CSS selector"))
60}
61
62static SEL_COMMENT_AVATAR: OnceLock<Selector> = OnceLock::new();
63fn sel_comment_avatar() -> &'static Selector {
64    SEL_COMMENT_AVATAR.get_or_init(|| Selector::parse(".commentthread_comment_avatar img").expect("valid CSS selector"))
65}
66
67static SEL_COMMENT_TIMESTAMP: OnceLock<Selector> = OnceLock::new();
68fn sel_comment_timestamp() -> &'static Selector {
69    SEL_COMMENT_TIMESTAMP.get_or_init(|| Selector::parse(".commentthread_comment_timestamp").expect("valid CSS selector"))
70}
71
72use crate::{
73    client::SteamUser,
74    endpoint::steam_endpoint,
75    error::SteamUserError,
76    types::{ActivityAchievement, ActivityApp, ActivityAuthor, ActivityComment, ActivityCommentResponse, ActivityGroup, ActivityPlayer, ActivityType, FriendActivity, FriendActivityResponse},
77    utils::avatar::{extract_custom_url, get_avatar_hash_from_url},
78};
79
80impl SteamUser {
81    /// Retrieves the friend activity feed.
82    ///
83    /// Fetches activity items from the authenticated user's activity feed,
84    /// including game purchases, achievements, wishlist additions, and more.
85    ///
86    /// # Arguments
87    ///
88    /// * `start` - Optional Unix timestamp to start fetching from (for
89    ///   pagination).
90    ///
91    /// # Returns
92    ///
93    /// Returns a [`FriendActivityResponse`] containing activities and
94    /// pagination info.
95    ///
96    /// # Example
97    ///
98    /// ```rust,no_run
99    /// # use steam_user::client::SteamUser;
100    /// # async fn example(mut user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
101    /// let response = user.get_friend_activity(None).await?;
102    /// for activity in response.activities {
103    ///     println!("Activity: {:?}", activity.activity_type);
104    /// }
105    /// // Fetch next page if available
106    /// if let Some(next_start) = response.next_request_timestart {
107    ///     let next_page = user.get_friend_activity(Some(next_start)).await?;
108    /// }
109    /// # Ok(())
110    /// # }
111    /// ```
112    #[steam_endpoint(GET, host = Community, path = "/profiles/{steam_id}/ajaxgetusernews/", kind = Read)]
113    pub async fn get_friend_activity(&self, start: Option<u64>) -> Result<FriendActivityResponse, SteamUserError> {
114        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
115        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));
116
117        let response: serde_json::Value = self.get_path(format!("/profiles/{}/ajaxgetusernews/?start={}", steam_id.steam_id64(), start_ts)).send().await?.json().await?;
118
119        let success = response.get("success").and_then(|v| v.as_bool()).unwrap_or(false);
120
121        if !success {
122            return Ok(FriendActivityResponse::default());
123        }
124
125        let next_request = response.get("next_request").and_then(|v| v.as_str()).map(|s| s.to_string());
126
127        let next_request_timestart = next_request.as_ref().and_then(|url| url.split("?start=").last().and_then(|s| s.parse::<u64>().ok()));
128
129        let blotter_html = response.get("blotter_html").and_then(|v| v.as_str()).unwrap_or("").to_string();
130
131        // Activity feeds re-parse a fresh HTML fragment per blotter block; for
132        // accounts with many days of activity this is the heaviest CPU path
133        // in this service, so run it on the blocking pool.
134        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}")))?;
135
136        Ok(FriendActivityResponse { activities, next_request_timestart, next_request_url: next_request })
137    }
138
139    /// Retrieves the complete friend activity feed by fetching all pages.
140    ///
141    /// This method will continue fetching pages until no more activities are
142    /// available. Use with caution as this may make many HTTP requests.
143    ///
144    /// # Returns
145    ///
146    /// Returns a `Vec<FriendActivity>` containing all activities from the feed.
147    ///
148    /// # Example
149    ///
150    /// ```rust,no_run
151    /// # use steam_user::client::SteamUser;
152    /// # async fn example(mut user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
153    /// let all_activities = user.get_friend_activity_full().await?;
154    /// println!("Found {} total activities", all_activities.len());
155    /// # Ok(())
156    /// # }
157    /// ```
158    // delegates to `get_friend_activity` (paginated) — no #[steam_endpoint]
159    #[tracing::instrument(skip(self))]
160    pub async fn get_friend_activity_full(&self) -> Result<Vec<FriendActivity>, SteamUserError> {
161        let mut all_activities = Vec::new();
162        let mut next_start: Option<u64> = None;
163
164        loop {
165            let response = self.get_friend_activity(next_start).await?;
166            all_activities.extend(response.activities);
167
168            match response.next_request_timestart {
169                Some(ts) => next_start = Some(ts),
170                None => break,
171            }
172        }
173
174        Ok(all_activities)
175    }
176
177    /// Comments on a friend's game purchase activity.
178    ///
179    /// # Arguments
180    ///
181    /// * `steam_id` - The SteamID of the user who received the game.
182    /// * `thread_id` - The thread ID of the activity (from
183    ///   [`FriendActivity::thread_id`]).
184    /// * `comment` - The comment text to post.
185    ///
186    /// # Returns
187    ///
188    /// Returns an [`ActivityCommentResponse`] with the result of the operation.
189    ///
190    /// # Example
191    ///
192    /// ```rust,no_run
193    /// # use steam_user::client::SteamUser;
194    /// # use steamid::SteamID;
195    /// # async fn example(mut user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
196    /// let friend_id = SteamID::from(76561198012345678u64);
197    /// let result = user
198    ///     .comment_user_received_new_game(friend_id, 1234567890, "Nice game!")
199    ///     .await?;
200    /// println!("Comment posted: {}", result.success);
201    /// # Ok(())
202    /// # }
203    /// ```
204    #[steam_endpoint(POST, host = Community, path = "/comment/UserReceivedNewGame/post/{steam_id}/{thread_id}/", kind = Write)]
205    pub async fn comment_user_received_new_game(&self, steam_id: SteamID, thread_id: u64, comment: &str) -> Result<ActivityCommentResponse, SteamUserError> {
206        let form = [("comment", comment), ("count", "3"), ("feature2", "-1"), ("newestfirstpagination", "true")];
207
208        let response: serde_json::Value = self.post_path(format!("/comment/UserReceivedNewGame/post/{}/{}/", steam_id.steam_id64(), thread_id)).form(&form).send().await?.json().await?;
209
210        Ok(parse_comment_response(&response))
211    }
212
213    /// Upvotes (likes) a friend's game purchase activity.
214    ///
215    /// # Arguments
216    ///
217    /// * `steam_id` - The SteamID of the user who received the game.
218    /// * `thread_id` - The thread ID of the activity (from
219    ///   [`FriendActivity::thread_id`]).
220    ///
221    /// # Returns
222    ///
223    /// Returns an [`ActivityCommentResponse`] with the result of the operation.
224    ///
225    /// # Example
226    ///
227    /// ```rust,no_run
228    /// # use steam_user::client::SteamUser;
229    /// # use steamid::SteamID;
230    /// # async fn example(mut user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
231    /// let friend_id = SteamID::from(76561198012345678u64);
232    /// let result = user
233    ///     .rate_up_user_received_new_game(friend_id, 1234567890)
234    ///     .await?;
235    /// println!(
236    ///     "Upvoted: {}, total upvotes: {}",
237    ///     result.has_upvoted, result.upvotes
238    /// );
239    /// # Ok(())
240    /// # }
241    /// ```
242    #[steam_endpoint(POST, host = Community, path = "/comment/UserReceivedNewGame/voteup/{steam_id}/{thread_id}/", kind = Write)]
243    pub async fn rate_up_user_received_new_game(&self, steam_id: SteamID, thread_id: u64) -> Result<ActivityCommentResponse, SteamUserError> {
244        let form = [("vote", "1"), ("count", "3"), ("feature2", "-1"), ("newestfirstpagination", "true")];
245
246        let response: serde_json::Value = self.post_path(format!("/comment/UserReceivedNewGame/voteup/{}/{}/", steam_id.steam_id64(), thread_id)).form(&form).send().await?.json().await?;
247
248        Ok(parse_comment_response(&response))
249    }
250
251    /// Deletes a comment from a friend's game purchase activity.
252    ///
253    /// # Arguments
254    ///
255    /// * `steam_id` - The SteamID of the user who received the game.
256    /// * `thread_id` - The thread ID of the activity (from
257    ///   [`FriendActivity::thread_id`]).
258    /// * `comment_id` - The ID of the comment to delete.
259    ///
260    /// # Returns
261    ///
262    /// Returns an [`ActivityCommentResponse`] with the result of the operation.
263    ///
264    /// # Example
265    ///
266    /// ```rust,no_run
267    /// # use steam_user::client::SteamUser;
268    /// # use steamid::SteamID;
269    /// # async fn example(mut user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
270    /// let friend_id = SteamID::from(76561198012345678u64);
271    /// let result = user
272    ///     .delete_comment_user_received_new_game(friend_id, 1234567890, "6407003171827779878")
273    ///     .await?;
274    /// println!("Deleted: {}", result.success);
275    /// # Ok(())
276    /// # }
277    /// ```
278    #[steam_endpoint(POST, host = Community, path = "/comment/UserReceivedNewGame/delete/{steam_id}/{thread_id}/", kind = Write)]
279    pub async fn delete_comment_user_received_new_game(&self, steam_id: SteamID, thread_id: u64, comment_id: &str) -> Result<ActivityCommentResponse, SteamUserError> {
280        let form = [("gidcomment", comment_id), ("start", "0"), ("count", "3"), ("feature2", "-1"), ("newestfirstpagination", "true")];
281
282        let response: serde_json::Value = self.post_path(format!("/comment/UserReceivedNewGame/delete/{}/{}/", steam_id.steam_id64(), thread_id)).form(&form).send().await?.json().await?;
283
284        Ok(parse_comment_response(&response))
285    }
286}
287
288/// Parses the activity feed HTML into structured activity items.
289fn parse_activity_feed(html: &str) -> Vec<FriendActivity> {
290    let cleaned_html = html.replace(['\t', '\n', '\r'], "");
291    let document = Html::parse_document(&format!("<div>{}</div>", cleaned_html));
292    let mut activities = Vec::new();
293
294    for day_element in document.select(sel_blotter_day()) {
295        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);
296
297        let header_date = day_element.select(sel_blotter_day_header()).next().map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
298
299        for block_element in day_element.select(sel_blotter_block()) {
300            let block_html = block_element.html();
301            let block_doc = Html::parse_fragment(&block_html);
302
303            // Determine activity type from classes
304            let activity_type = determine_activity_type(&block_doc);
305
306            let mut activity = match activity_type {
307                ActivityType::DailyRollup => parse_blotter_daily_rollup(&block_doc),
308                ActivityType::GamePurchase => parse_blotter_game_purchase(&block_doc),
309                _ => FriendActivity { activity_type: activity_type.clone(), ..Default::default() },
310            };
311
312            activity.timestamp = timestamp;
313            activity.header_date = header_date.clone();
314            activities.push(activity);
315        }
316    }
317
318    activities
319}
320
321/// Determines the activity type from the blotter block.
322fn determine_activity_type(doc: &Html) -> ActivityType {
323    let html = doc.html();
324
325    if html.contains("blotter_daily_rollup") {
326        ActivityType::DailyRollup
327    } else if html.contains("blotter_gamepurchase") {
328        ActivityType::GamePurchase
329    } else if html.contains("blotter_workshopitempublished") {
330        ActivityType::WorkshopItemPublished
331    } else if html.contains("blotter_recommendation") {
332        ActivityType::Recommendation
333    } else if html.contains("blotter_userstatus") {
334        ActivityType::UserStatus
335    } else if html.contains("blotter_screenshot") {
336        ActivityType::Screenshot
337    } else if html.contains("blotter_videopublished") {
338        ActivityType::VideoPublished
339    } else {
340        ActivityType::Unknown("unknown".to_string())
341    }
342}
343
344/// Parses a daily rollup blotter block.
345fn parse_blotter_daily_rollup(doc: &Html) -> FriendActivity {
346    let mut activity = FriendActivity { activity_type: ActivityType::DailyRollup, ..Default::default() };
347
348    for line_element in doc.select(sel_blotter_daily_rollup_line()) {
349        let line_html = line_element.html();
350        let line_doc = Html::parse_fragment(&line_html);
351
352        // Get content text to determine sub-type
353        let content_text = line_element.text().collect::<String>();
354
355        // Parse players
356        let players = parse_player_list_from_blotter(&line_doc);
357
358        // Parse apps
359        let apps = parse_app_list_from_blotter(&line_doc);
360
361        // Parse achieved
362        let achieved = parse_achieved_from_blotter(&line_doc);
363
364        // Parse groups
365        let groups = parse_group_list_from_blotter(&line_doc);
366
367        // Merge into activity
368        activity.players.extend(players);
369        activity.apps.extend(apps);
370        activity.achieved.extend(achieved);
371        activity.groups.extend(groups);
372
373        // Try to determine more specific type
374        if content_text.contains("are now friends") || content_text.contains("is now friends with") {
375            activity.activity_type = ActivityType::NewFriend;
376        } else if content_text.contains("played") && content_text.contains("for the first time") {
377            activity.activity_type = ActivityType::PlayedFirstTime;
378        } else if content_text.contains("achieved") {
379            activity.activity_type = ActivityType::Achieved;
380        } else if content_text.contains("has added") && content_text.contains("to their wishlist") {
381            activity.activity_type = ActivityType::AddedToWishlist;
382        } else if content_text.contains("is now following") {
383            activity.activity_type = ActivityType::Following;
384        } else if content_text.contains("has joined") {
385            activity.activity_type = ActivityType::Joined;
386        }
387    }
388
389    activity
390}
391
392/// Parses a game purchase blotter block.
393fn parse_blotter_game_purchase(doc: &Html) -> FriendActivity {
394    let mut activity = FriendActivity { activity_type: ActivityType::GamePurchase, ..Default::default() };
395
396    // Parse author
397    if let Some(avatar_el) = doc.select(sel_author_avatar()).next() {
398        let avatar_src = avatar_el.value().attr("src").unwrap_or("");
399        let avatar_hash = get_avatar_hash_from_url(avatar_src).unwrap_or_default();
400
401        if let Some(author_el) = doc.select(sel_author_link()).next() {
402            let miniprofile = author_el.value().attr("data-miniprofile").and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
403
404            let profile_url = author_el.value().attr("href").unwrap_or("").to_string();
405            let custom_url = extract_custom_url(&profile_url);
406
407            // Get name (skip nickname elements)
408            let name = author_el.text().collect::<String>().trim().to_string();
409
410            activity.author = Some(ActivityAuthor {
411                name,
412                nickname: None,
413                avatar_hash,
414                miniprofile,
415                steam_id: SteamID::from_individual_account_id(u32::try_from(miniprofile).unwrap_or(0)),
416                profile_url,
417                custom_url,
418            });
419        }
420    }
421
422    // Parse apps from content and details
423    activity.apps = parse_app_list_from_blotter(doc);
424
425    // Parse thread ID for commenting
426    activity.thread_id = parse_thread_id(doc);
427
428    // Parse comments
429    activity.comments = parse_activity_comments(doc);
430
431    activity
432}
433
434/// Parses player list from blotter content.
435fn parse_player_list_from_blotter(doc: &Html) -> Vec<ActivityPlayer> {
436    let mut players = Vec::new();
437
438    for element in doc.select(sel_author_link()) {
439        let miniprofile = element.value().attr("data-miniprofile").and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
440
441        if miniprofile == 0 {
442            continue;
443        }
444
445        let name = element.text().collect::<String>().trim().to_string();
446
447        players.push(ActivityPlayer { name, nickname: None, miniprofile, steam_id: SteamID::from_individual_account_id(u32::try_from(miniprofile).unwrap_or(0)) });
448    }
449
450    players
451}
452
453/// Parses app list from blotter content.
454fn parse_app_list_from_blotter(doc: &Html) -> Vec<ActivityApp> {
455    let mut apps = Vec::new();
456
457    for element in doc.select(sel_app_links()) {
458        let link = element.value().attr("href").unwrap_or("").to_string();
459        let name = element.text().collect::<String>().trim().to_string();
460        let id = parse_app_id_from_link(&link);
461
462        if id > 0 {
463            apps.push(ActivityApp { id, name, link });
464        }
465    }
466
467    // Deduplicate by ID
468    apps.sort_by_key(|a| a.id);
469    apps.dedup_by_key(|a| a.id);
470
471    apps
472}
473
474/// Parses achievements from blotter content.
475fn parse_achieved_from_blotter(doc: &Html) -> Vec<ActivityAchievement> {
476    let mut achieved = Vec::new();
477
478    for element in doc.select(sel_img_title()) {
479        let title = element.value().attr("title").unwrap_or("").to_string();
480        let img = element.value().attr("src").unwrap_or("").to_string();
481
482        if !title.is_empty() && img.contains("achievement") {
483            achieved.push(ActivityAchievement { title, img });
484        }
485    }
486
487    achieved
488}
489
490/// Parses group list from blotter content.
491fn parse_group_list_from_blotter(doc: &Html) -> Vec<ActivityGroup> {
492    let mut groups = Vec::new();
493
494    for element in doc.select(sel_group_links()) {
495        let link = element.value().attr("href").unwrap_or("").to_string();
496        let name = element.text().collect::<String>().trim().to_string();
497        let url = link.split("steamcommunity.com/groups/").nth(1).unwrap_or("").trim_end_matches('/').to_string();
498
499        if !url.is_empty() {
500            groups.push(ActivityGroup { name, link, url });
501        }
502    }
503
504    groups
505}
506
507/// Parses thread ID for commenting from blotter block.
508fn parse_thread_id(doc: &Html) -> Option<u64> {
509    let html = doc.html();
510
511    // Try to find in onclick attribute
512    if let Some(start) = html.find("UserReceivedNewGame_") {
513        let rest = &html[start..];
514        if let Some(end) = rest.find('\'') {
515            let id_part = &rest[..end];
516            // Extract the thread ID (last number after underscore)
517            if let Some(last_underscore) = id_part.rfind('_') {
518                if let Ok(id) = id_part[last_underscore + 1..].parse::<u64>() {
519                    return Some(id);
520                }
521            }
522        }
523    }
524
525    // Try to find in form/paging element IDs
526    if let Some(start) = html.find("commentthread_UserReceivedNewGame_") {
527        let rest = &html[start..];
528        // Find the thread ID between underscores
529        let parts: Vec<&str> = rest.split('_').collect();
530        if parts.len() >= 3 {
531            if let Ok(id) = parts[2].parse::<u64>() {
532                return Some(id);
533            }
534        }
535    }
536
537    None
538}
539
540/// Parses comments from an activity block.
541fn parse_activity_comments(doc: &Html) -> Vec<ActivityComment> {
542    let mut comments = Vec::new();
543
544    for element in doc.select(sel_comment_thread()) {
545        let id = element.value().attr("id").unwrap_or("").replace("comment_", "");
546
547        if id.is_empty() {
548            continue;
549        }
550
551        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();
552
553        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);
554
555        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);
556
557        comments.push(ActivityComment {
558            id,
559            author_steam_id: SteamID::from_individual_account_id(u32::try_from(author_miniprofile).unwrap_or(0)),
560            author_miniprofile,
561            author_avatar_hash,
562            timestamp,
563        });
564    }
565
566    comments
567}
568
569/// Parses an app ID from a Steam store/community URL.
570fn parse_app_id_from_link(link: &str) -> u32 {
571    let prefixes = ["steamcommunity.com/app/", "store.steampowered.com/app/", "store.steampowered.com/sub/"];
572
573    for prefix in prefixes {
574        if let Some(start) = link.find(prefix) {
575            let rest = &link[start + prefix.len()..];
576            let id_str = rest.split('/').next().unwrap_or("");
577            if let Ok(id) = id_str.parse::<u32>() {
578                return id;
579            }
580        }
581    }
582
583    0
584}
585
586/// Parses the comment response JSON.
587fn parse_comment_response(response: &serde_json::Value) -> ActivityCommentResponse {
588    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);
589
590    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);
591
592    let upvotes = response.get("upvotes").and_then(|v| v.as_u64()).map(|n| u32::try_from(n).unwrap_or(u32::MAX)).unwrap_or(0);
593
594    let has_upvoted = response.get("has_upvoted").and_then(|v| v.as_i64()).map(|n| n == 1).unwrap_or(false);
595
596    ActivityCommentResponse { success, total_count, upvotes, has_upvoted }
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602
603    #[test]
604    fn test_parse_app_id_from_link() {
605        assert_eq!(parse_app_id_from_link("https://store.steampowered.com/app/730/Counter-Strike_2/"), 730);
606        assert_eq!(parse_app_id_from_link("https://steamcommunity.com/app/570"), 570);
607        assert_eq!(parse_app_id_from_link("https://store.steampowered.com/sub/469"), 469);
608        assert_eq!(parse_app_id_from_link("https://example.com"), 0);
609    }
610
611    #[test]
612    fn test_determine_activity_type() {
613        let doc = Html::parse_fragment("<div class=\"blotter_gamepurchase\">test</div>");
614        assert_eq!(determine_activity_type(&doc), ActivityType::GamePurchase);
615
616        let doc = Html::parse_fragment("<div class=\"blotter_daily_rollup\">test</div>");
617        assert_eq!(determine_activity_type(&doc), ActivityType::DailyRollup);
618    }
619}