1use 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 #[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 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 #[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 #[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 #[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 #[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
288fn 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 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
321fn 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
344fn 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 let content_text = line_element.text().collect::<String>();
354
355 let players = parse_player_list_from_blotter(&line_doc);
357
358 let apps = parse_app_list_from_blotter(&line_doc);
360
361 let achieved = parse_achieved_from_blotter(&line_doc);
363
364 let groups = parse_group_list_from_blotter(&line_doc);
366
367 activity.players.extend(players);
369 activity.apps.extend(apps);
370 activity.achieved.extend(achieved);
371 activity.groups.extend(groups);
372
373 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
392fn parse_blotter_game_purchase(doc: &Html) -> FriendActivity {
394 let mut activity = FriendActivity { activity_type: ActivityType::GamePurchase, ..Default::default() };
395
396 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 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 activity.apps = parse_app_list_from_blotter(doc);
424
425 activity.thread_id = parse_thread_id(doc);
427
428 activity.comments = parse_activity_comments(doc);
430
431 activity
432}
433
434fn 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
453fn 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 apps.sort_by_key(|a| a.id);
469 apps.dedup_by_key(|a| a.id);
470
471 apps
472}
473
474fn 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
490fn 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
507fn parse_thread_id(doc: &Html) -> Option<u64> {
509 let html = doc.html();
510
511 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 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 if let Some(start) = html.find("commentthread_UserReceivedNewGame_") {
527 let rest = &html[start..];
528 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
540fn 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
569fn 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
586fn 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}