Skip to main content

steam_user/services/
apps.rs

1use std::{collections::HashMap, sync::OnceLock, time::Duration};
2
3use scraper::{Html, Selector};
4use serde_json::Value;
5
6use crate::{
7    client::SteamUser,
8    endpoint::steam_endpoint,
9    error::SteamUserError,
10    types::apps::{AppDetail, CsgoAccountStats, OwnedApp},
11};
12
13/// Connect timeout for the ad-hoc clients in this module (static endpoints
14/// that don't go through `SteamUser`'s shared client).
15const ADHOC_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
16
17/// Total request timeout for the ad-hoc clients in this module.
18const ADHOC_TIMEOUT: Duration = Duration::from_secs(60);
19
20/// Build a one-off `reqwest::Client` with the project-standard timeouts.
21fn build_adhoc_client() -> Result<reqwest::Client, SteamUserError> {
22    reqwest::Client::builder().connect_timeout(ADHOC_CONNECT_TIMEOUT).timeout(ADHOC_TIMEOUT).build().map_err(SteamUserError::from)
23}
24
25static SEL_KV_LINE: OnceLock<Selector> = OnceLock::new();
26fn sel_kv_line() -> &'static Selector {
27    SEL_KV_LINE.get_or_init(|| Selector::parse(".generic_kv_table .generic_kv_line").expect("valid CSS selector"))
28}
29
30static SEL_KV_TABLE: OnceLock<Selector> = OnceLock::new();
31fn sel_kv_table() -> &'static Selector {
32    SEL_KV_TABLE.get_or_init(|| Selector::parse("table.generic_kv_table").expect("valid CSS selector"))
33}
34
35static SEL_TR: OnceLock<Selector> = OnceLock::new();
36fn sel_tr() -> &'static Selector {
37    SEL_TR.get_or_init(|| Selector::parse("tr").expect("valid CSS selector"))
38}
39
40static SEL_TH: OnceLock<Selector> = OnceLock::new();
41fn sel_th() -> &'static Selector {
42    SEL_TH.get_or_init(|| Selector::parse("th").expect("valid CSS selector"))
43}
44
45static SEL_TD: OnceLock<Selector> = OnceLock::new();
46fn sel_td() -> &'static Selector {
47    SEL_TD.get_or_init(|| Selector::parse("td").expect("valid CSS selector"))
48}
49
50static SEL_MATCH_ANCHOR: OnceLock<Selector> = OnceLock::new();
51fn sel_match_anchor() -> &'static Selector {
52    SEL_MATCH_ANCHOR.get_or_init(|| Selector::parse("a.match").expect("valid CSS selector"))
53}
54
55static SEL_MATCH_NAME: OnceLock<Selector> = OnceLock::new();
56fn sel_match_name() -> &'static Selector {
57    SEL_MATCH_NAME.get_or_init(|| Selector::parse(".match_name").expect("valid CSS selector"))
58}
59
60static SEL_MATCH_IMG: OnceLock<Selector> = OnceLock::new();
61fn sel_match_img() -> &'static Selector {
62    SEL_MATCH_IMG.get_or_init(|| Selector::parse(".match_img img").expect("valid CSS selector"))
63}
64
65static SEL_MATCH_PRICE: OnceLock<Selector> = OnceLock::new();
66fn sel_match_price() -> &'static Selector {
67    SEL_MATCH_PRICE.get_or_init(|| Selector::parse(".match_price").expect("valid CSS selector"))
68}
69
70static SEL_SEARCH_ROW: OnceLock<Selector> = OnceLock::new();
71fn sel_search_row() -> &'static Selector {
72    SEL_SEARCH_ROW.get_or_init(|| Selector::parse("a.search_result_row").expect("valid CSS selector"))
73}
74
75static SEL_SEARCH_NAME: OnceLock<Selector> = OnceLock::new();
76fn sel_search_name() -> &'static Selector {
77    SEL_SEARCH_NAME.get_or_init(|| Selector::parse(".search_name .title").expect("valid CSS selector"))
78}
79
80static SEL_SEARCH_CAPSULE_IMG: OnceLock<Selector> = OnceLock::new();
81fn sel_search_capsule_img() -> &'static Selector {
82    SEL_SEARCH_CAPSULE_IMG.get_or_init(|| Selector::parse(".search_capsule img").expect("valid CSS selector"))
83}
84
85static SEL_SEARCH_PRICE: OnceLock<Selector> = OnceLock::new();
86fn sel_search_price() -> &'static Selector {
87    SEL_SEARCH_PRICE.get_or_init(|| Selector::parse(".search_price").expect("valid CSS selector"))
88}
89
90static SEL_APP_CONFIG: OnceLock<Selector> = OnceLock::new();
91fn sel_app_config() -> &'static Selector {
92    SEL_APP_CONFIG.get_or_init(|| Selector::parse("#application_config").expect("valid CSS selector"))
93}
94
95impl SteamUser {
96    /// Retrieves the list of apps owned by the currently authenticated user.
97    ///
98    /// Fetches the list from `https://steamcommunity.com/actions/GetOwnedApps/`.
99    ///
100    /// # Returns
101    ///
102    /// Returns a `Vec<OwnedApp>` containing details about each app owned by the
103    /// user.
104    ///
105    /// # Example
106    ///
107    /// ```rust,no_run
108    /// # use steam_user::client::SteamUser;
109    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
110    /// let apps = user.get_owned_apps().await?;
111    /// println!("You own {} apps.", apps.len());
112    /// # Ok(())
113    /// # }
114    /// ```
115    #[steam_endpoint(GET, host = Community, path = "/actions/GetOwnedApps/", kind = Read)]
116    pub async fn get_owned_apps(&self) -> Result<Vec<OwnedApp>, SteamUserError> {
117        let response: Value = self.get_path("/actions/GetOwnedApps/").send().await?.json().await?;
118
119        let apps = response.as_array().ok_or_else(|| SteamUserError::MalformedResponse("Expected an array of apps".into()))?;
120
121        let mut owned_apps = Vec::new();
122        for app in apps {
123            if let Ok(owned_app) = serde_json::from_value::<OwnedApp>(app.clone()) {
124                owned_apps.push(owned_app);
125            }
126        }
127
128        Ok(owned_apps)
129    }
130
131    /// Retrieves details for one or more Steam applications.
132    ///
133    /// Fetches data from the Steam Store API at `https://store.steampowered.com/api/appdetails`.
134    ///
135    /// # Arguments
136    ///
137    /// * `app_ids` - A slice of Steam App IDs to fetch details for.
138    ///
139    /// # Returns
140    ///
141    /// Returns a `HashMap<u32, AppDetail>` where the key is the App ID and the
142    /// value is the [`AppDetail`].
143    ///
144    /// # Example
145    ///
146    /// ```rust,no_run
147    /// # use steam_user::client::SteamUser;
148    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
149    /// let details = user.get_app_detail(&[730, 440]).await?;
150    /// if let Some(csgo) = details.get(&730) {
151    ///     println!("App name: {}", csgo.name);
152    /// }
153    /// # Ok(())
154    /// # }
155    /// ```
156    #[steam_endpoint(GET, host = Store, path = "/api/appdetails", kind = Read)]
157    pub async fn get_app_detail(&self, app_ids: &[u32]) -> Result<HashMap<u32, AppDetail>, SteamUserError> {
158        if app_ids.is_empty() {
159            return Ok(HashMap::new());
160        }
161
162        let ids = app_ids.iter().map(|id| id.to_string()).collect::<Vec<String>>().join(",");
163
164        let response: Value = self.get_path("/api/appdetails").query(&[("appids", ids.as_str()), ("hl", "en")]).send().await?.json().await?;
165
166        let mut details = HashMap::new();
167        if let Some(obj) = response.as_object() {
168            for (key, val) in obj {
169                if let Ok(app_id) = key.parse::<u32>() {
170                    if val["success"].as_bool().unwrap_or(false) {
171                        if let Ok(detail) = serde_json::from_value::<AppDetail>(val["data"].clone()) {
172                            details.insert(app_id, detail);
173                        }
174                    }
175                }
176            }
177        }
178
179        Ok(details)
180    }
181
182    /// Fetches CS:GO account statistics from the Steam GDPR page.
183    ///
184    /// Scrapes the GDPR activity page for CS:GO (App ID 730) at `https://steamcommunity.com/my/gcpd/730/?tab=accountmain`.
185    /// This includes data like last logout, first played, and profile rank.
186    ///
187    /// # Returns
188    ///
189    /// Returns a [`CsgoAccountStats`] struct containing various account
190    /// statistics.
191    ///
192    /// # Example
193    ///
194    /// ```rust,no_run
195    /// # use steam_user::client::SteamUser;
196    /// # async fn example(mut user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
197    /// let stats = user.fetch_csgo_account_stats().await?;
198    /// if let Some(rank) = stats.profile_rank {
199    ///     println!("CS:GO Profile Rank: {}", rank);
200    /// }
201    /// # Ok(())
202    /// # }
203    /// ```
204    #[steam_endpoint(GET, host = Community, path = "/my/gcpd/730/", kind = Read)]
205    pub async fn fetch_csgo_account_stats(&self) -> Result<CsgoAccountStats, SteamUserError> {
206        let html = self.my_profile_get("gcpd/730/?tab=accountmain").await?;
207        let document = Html::parse_document(&html);
208
209        let mut stats = CsgoAccountStats {
210            last_logout_csgo: None,
211            last_launch_steam_client: None,
212            start_play_csgo: None,
213            first_played_cs_franchise: None,
214            last_known_ip: None,
215            earned_service_medal: None,
216            profile_rank: None,
217            xp_to_next_rank: None,
218            anti_addiction_online_time: None,
219        };
220
221        // Parse key-value lines
222        for element in document.select(sel_kv_line()) {
223            let text = element.text().collect::<String>().trim().to_string();
224            let mut parts = text.splitn(2, ": ");
225            if let (Some(raw_key), Some(raw_value)) = (parts.next(), parts.next()) {
226                let key = raw_key.trim();
227                let value = raw_value.trim().to_string();
228
229                match key {
230                    "Logged out of CS:GO" => stats.last_logout_csgo = Some(value),
231                    "Launched CS:GO using Steam Client" | "Launched CSGO using Steam Client" => stats.last_launch_steam_client = Some(value),
232                    "Started playing CS:GO" | "Started playing CSGO" => stats.start_play_csgo = Some(value),
233                    "First Counter-Strike franchise game" => stats.first_played_cs_franchise = Some(value),
234                    "Last known IP address" => stats.last_known_ip = Some(value),
235                    "Earned a Service Medal" => stats.earned_service_medal = Some(value),
236                    "CS:GO Profile Rank" | "CSGO Profile Rank" => stats.profile_rank = value.parse().ok(),
237                    "Experience points earned towards next rank" => stats.xp_to_next_rank = value.parse().ok(),
238                    "Anti-addiction online time" => stats.anti_addiction_online_time = Some(value),
239                    _ => {}
240                }
241            }
242        }
243
244        // Parse activity table (some data might be in rows instead of single lines)
245        for table in document.select(sel_kv_table()) {
246            let mut rows = table.select(sel_tr());
247            if let Some(header_row) = rows.next() {
248                let headers: Vec<String> = header_row.select(sel_th()).map(|h| h.text().collect::<String>().trim().to_lowercase()).collect();
249
250                if headers.contains(&"recorded activity".to_string()) && headers.contains(&"activity time".to_string()) {
251                    for tr in rows {
252                        let cells: Vec<String> = tr.select(sel_td()).map(|c| c.text().collect::<String>().trim().to_string()).collect();
253                        if cells.len() >= 2 {
254                            let key = &cells[0];
255                            let value = cells[1].clone();
256                            match key.as_str() {
257                                "Logged out of CS:GO" => stats.last_logout_csgo = Some(value),
258                                "Launched CS:GO using Steam Client" => stats.last_launch_steam_client = Some(value),
259                                "Started playing CS:GO" => stats.start_play_csgo = Some(value),
260                                "First Counter-Strike franchise game" => stats.first_played_cs_franchise = Some(value),
261                                _ => {}
262                            }
263                        }
264                    }
265                }
266            }
267        }
268
269        Ok(stats)
270    }
271
272    /// Fetches Steam batched loyalty reward items for given app ID(s).
273    ///
274    /// Uses the `ILoyaltyRewardsService/BatchedQueryRewardItems` Protobuf API.
275    /// Loyalty rewards include items like profile backgrounds, emojis, and
276    /// animated avatars.
277    ///
278    /// # Arguments
279    ///
280    /// * `app_ids` - A slice of Steam App IDs to query reward items for.
281    ///
282    /// # Returns
283    ///
284    /// Returns a `Vec` of Protobuf responses containing reward item details for
285    /// each app.
286    ///
287    /// # Example
288    ///
289    /// ```rust,no_run
290    /// # use steam_user::client::SteamUser;
291    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
292    /// let rewards = user.fetch_batched_loyalty_reward_items(&[730]).await?;
293    /// println!("Found rewards for {} apps.", rewards.len());
294    /// # Ok(())
295    /// # }
296    /// ```
297    #[steam_endpoint(POST, host = Api, path = "/ILoyaltyRewardsService/BatchedQueryRewardItems/v1", kind = Read)]
298    pub async fn fetch_batched_loyalty_reward_items(&self, app_ids: &[u32]) -> Result<Vec<steam_protos::messages::CLoyaltyRewardsBatchedQueryRewardItemsResponseResponse>, SteamUserError> {
299        use prost::Message;
300        use steam_protos::messages::{CLoyaltyRewardsBatchedQueryRewardItemsRequest, CLoyaltyRewardsBatchedQueryRewardItemsResponse, CLoyaltyRewardsQueryRewardItemsRequest};
301
302        if app_ids.is_empty() {
303            return Ok(Vec::new());
304        }
305
306        let request = CLoyaltyRewardsBatchedQueryRewardItemsRequest {
307            requests: app_ids
308                .iter()
309                .map(|&app_id| CLoyaltyRewardsQueryRewardItemsRequest {
310                    appids: vec![app_id],
311                    time_available: None,
312                    community_item_classes: Vec::new(),
313                    language: Some("english".to_string()),
314                    count: Some(10),
315                    cursor: None,
316                    sort: Some(1),
317                    sort_descending: Some(true),
318                    reward_types: Vec::new(),
319                    excluded_community_item_classes: Vec::new(),
320                    definitionids: Vec::new(),
321                    filters: Vec::new(),
322                    filter_match_all_category_tags: Vec::new(),
323                    filter_match_any_category_tags: Vec::new(),
324                    contains_definitionids: Vec::new(),
325                    include_direct_purchase_disabled: None,
326                    excluded_content_descriptors: vec![3, 4],
327                    excluded_appids: Vec::new(),
328                    excluded_store_tagids: Vec::new(),
329                    store_tagids: Vec::new(),
330                    search_term: None,
331                })
332                .collect(),
333        };
334
335        let mut body = Vec::new();
336        request.encode(&mut body)?;
337
338        let params = [("origin", "https://store.steampowered.com"), ("input_protobuf_encoded", &base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &body))];
339
340        let response = self.get_path("/ILoyaltyRewardsService/BatchedQueryRewardItems/v1").query(&params).send().await?;
341
342        if !response.status().is_success() {
343            return Err(SteamUserError::HttpStatus {
344                status: response.status().as_u16(),
345                url: response.url().to_string(),
346            });
347        }
348
349        let bytes = response.bytes().await?;
350        let response_proto = CLoyaltyRewardsBatchedQueryRewardItemsResponse::decode(bytes)?;
351
352        Ok(response_proto.responses)
353    }
354
355    /// Retrieves detailed information about all games the user owns.
356    ///
357    /// Scrapes the games page at `https://steamcommunity.com/my/games/?tab=all`
358    /// and parses the `rgGames` JavaScript variable.
359    ///
360    /// # Returns
361    ///
362    /// Returns a `Vec<OwnedAppDetail>` containing detailed info about each
363    /// owned game.
364    #[steam_endpoint(GET, host = Community, path = "/my/games/", kind = Read)]
365    pub async fn get_owned_apps_detail(&self) -> Result<Vec<crate::types::apps::OwnedAppDetail>, SteamUserError> {
366        let html = self.my_profile_get("games/?tab=all").await?;
367
368        // Find rgGames variable in the HTML
369        let start_marker = "var rgGames = ";
370        let end_marker = "var rgChangingGames = []";
371
372        let start = html.find(start_marker).ok_or_else(|| SteamUserError::MalformedResponse("rgGames not found".into()))?;
373
374        let rest = &html[start + start_marker.len()..];
375        let end = rest.find(end_marker).ok_or_else(|| SteamUserError::MalformedResponse("rgChangingGames not found".into()))?;
376
377        let json_str = rest[..end].trim().trim_end_matches(';').trim();
378
379        let apps: Vec<crate::types::apps::OwnedAppDetail> = serde_json::from_str(json_str).map_err(|e| SteamUserError::MalformedResponse(format!("Failed to parse rgGames: {}", e)))?;
380
381        Ok(apps)
382    }
383
384    /// Retrieves dynamic store user data including owned apps, wishlist, and
385    /// ignored apps.
386    ///
387    /// Fetches data from `https://store.steampowered.com/dynamicstore/userdata/`.
388    ///
389    /// # Returns
390    ///
391    /// Returns a `DynamicStoreUserData` containing lists of owned apps,
392    /// packages, wishlist, etc.
393    #[steam_endpoint(GET, host = Store, path = "/dynamicstore/userdata/", kind = Read)]
394    pub async fn get_dynamic_store_user_data(&self) -> Result<crate::types::apps::DynamicStoreUserData, SteamUserError> {
395        let response = self.get_path("/dynamicstore/userdata/").send().await?;
396
397        if !response.status().is_success() {
398            return Err(SteamUserError::HttpStatus {
399                status: response.status().as_u16(),
400                url: response.url().to_string(),
401            });
402        }
403
404        let data: crate::types::apps::DynamicStoreUserData = response.json().await?;
405        Ok(data)
406    }
407
408    /// Retrieves an array of Steam AppIDs that the current user owns.
409    ///
410    /// This is a convenience wrapper around `get_dynamic_store_user_data()`.
411    // delegates to `get_dynamic_store_user_data` — no #[steam_endpoint]
412    ///
413    /// # Returns
414    ///
415    /// Returns a `Vec<u32>` containing owned Steam AppIDs.
416    #[tracing::instrument(skip(self))]
417    pub async fn get_owned_apps_id(&self) -> Result<Vec<u32>, SteamUserError> {
418        let data = self.get_dynamic_store_user_data().await?;
419        Ok(data.owned_apps)
420    }
421
422    /// Fetches up-to-date status and version information for a Steam app.
423    ///
424    /// Uses the public API at `https://api.steampowered.com/ISteamApps/UpToDateCheck/v1/`.
425    /// This is a static method that doesn't require authentication.
426    ///
427    /// # Arguments
428    ///
429    /// * `app_id` - The Steam App ID to check.
430    ///
431    /// # Returns
432    ///
433    /// Returns a `SteamAppVersionInfo` containing update status information.
434    #[steam_endpoint(GET, host = Api, path = "/ISteamApps/UpToDateCheck/v1/", kind = Read)]
435    pub async fn get_steam_app_version_info(app_id: u32) -> Result<crate::types::apps::SteamAppVersionInfo, SteamUserError> {
436        let client = build_adhoc_client()?;
437        let response = client.get("https://api.steampowered.com/ISteamApps/UpToDateCheck/v1/").query(&[("format", "json"), ("appid", &app_id.to_string()), ("version", "0")]).send().await?;
438
439        if !response.status().is_success() {
440            return Err(SteamUserError::HttpStatus {
441                status: response.status().as_u16(),
442                url: response.url().to_string(),
443            });
444        }
445
446        let info: crate::types::apps::SteamAppVersionInfo = response.json().await?;
447        Ok(info)
448    }
449
450    /// Fetches a list of suggested Steam apps based on a search term.
451    ///
452    /// Uses the Steam store search suggestions at `https://store.steampowered.com/search/suggest`.
453    /// This is a static method that doesn't require authentication.
454    ///
455    /// # Arguments
456    ///
457    /// * `term` - The search keyword to suggest apps for.
458    ///
459    /// # Returns
460    ///
461    /// Returns a `Vec<AppListItem>` containing suggested apps.
462    #[steam_endpoint(GET, host = Store, path = "/search/suggest", kind = Read)]
463    pub async fn suggest_app_list(term: &str) -> Result<Vec<crate::types::apps::AppListItem>, SteamUserError> {
464        let client = build_adhoc_client()?;
465        let response = client.get("https://store.steampowered.com/search/suggest").query(&[("term", term), ("f", "games"), ("cc", "VN"), ("realm", "1"), ("l", "english"), ("use_store_query", "1")]).send().await?;
466
467        if !response.status().is_success() {
468            return Err(SteamUserError::HttpStatus {
469                status: response.status().as_u16(),
470                url: response.url().to_string(),
471            });
472        }
473
474        let html = response.text().await?;
475        let document = Html::parse_document(&html);
476
477        let mut items = Vec::new();
478        for element in document.select(sel_match_anchor()) {
479            let appid = element.value().attr("data-ds-appid").and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
480
481            let name = element.select(sel_match_name()).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
482
483            let img = element.select(sel_match_img()).next().and_then(|e| e.value().attr("src")).unwrap_or("").to_string();
484
485            let price = element.select(sel_match_price()).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
486
487            if appid > 0 {
488                items.push(crate::types::apps::AppListItem { appid, name, img, price });
489            }
490        }
491
492        Ok(items)
493    }
494
495    /// Fetches a list of Steam apps from the store search results.
496    ///
497    /// Uses the Steam store search at `https://store.steampowered.com/search/results/`.
498    /// This is a static method that doesn't require authentication.
499    ///
500    /// # Arguments
501    ///
502    /// * `term` - The search keyword to query.
503    ///
504    /// # Returns
505    ///
506    /// Returns a `Vec<AppListItem>` containing matched apps.
507    #[steam_endpoint(GET, host = Store, path = "/search/results/", kind = Read)]
508    pub async fn query_app_list(term: &str) -> Result<Vec<crate::types::apps::AppListItem>, SteamUserError> {
509        let client = build_adhoc_client()?;
510        let response = client.get("https://store.steampowered.com/search/results/").query(&[("query", ""), ("start", "0"), ("count", "50"), ("dynamic_data", ""), ("sort_by", "_ASC"), ("term", term), ("infinite", "1")]).send().await?;
511
512        if !response.status().is_success() {
513            return Err(SteamUserError::HttpStatus {
514                status: response.status().as_u16(),
515                url: response.url().to_string(),
516            });
517        }
518
519        #[derive(serde::Deserialize)]
520        struct SearchResponse {
521            results_html: Option<String>,
522        }
523
524        let data: SearchResponse = response.json().await?;
525        let results_html = data.results_html.unwrap_or_default();
526
527        let document = Html::parse_document(&results_html);
528
529        let mut items = Vec::new();
530        for element in document.select(sel_search_row()) {
531            let appid = element.value().attr("data-ds-appid").and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
532
533            let name = element.select(sel_search_name()).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
534
535            let img = element.select(sel_search_capsule_img()).next().and_then(|e| e.value().attr("src")).unwrap_or("").to_string();
536
537            let price = element.select(sel_search_price()).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
538
539            if appid > 0 {
540                items.push(crate::types::apps::AppListItem { appid, name, img, price });
541            }
542        }
543
544        Ok(items)
545    }
546
547    /// Retrieves the full list of Steam applications.
548    ///
549    /// Uses the public API at `https://api.steampowered.com/ISteamApps/GetAppList/v0002/`.
550    /// This is a static method that doesn't require authentication.
551    ///
552    /// # Returns
553    ///
554    /// Returns a `SimpleSteamAppList` containing all Steam applications.
555    #[steam_endpoint(GET, host = Api, path = "/ISteamApps/GetAppList/v0002/", kind = Read)]
556    pub async fn get_app_list() -> Result<crate::types::apps::SimpleSteamAppList, SteamUserError> {
557        let client = build_adhoc_client()?;
558        let response = client.get("https://api.steampowered.com/ISteamApps/GetAppList/v0002/?format=json").send().await?;
559
560        if !response.status().is_success() {
561            return Err(SteamUserError::HttpStatus {
562                status: response.status().as_u16(),
563                url: response.url().to_string(),
564            });
565        }
566
567        let list: crate::types::apps::SimpleSteamAppList = response.json().await?;
568        Ok(list)
569    }
570
571    /// Fetches CS2 matchmaking stats (cooldown, summary, per-map, last played).
572    ///
573    /// Scrapes `https://steamcommunity.com/my/gcpd/730/?tab=matchmaking`.
574    ///
575    /// # Returns
576    ///
577    /// Returns a [`MatchmakingStats`] with all four tables parsed from the
578    /// page.
579    #[steam_endpoint(GET, host = Community, path = "/my/gcpd/730/", kind = Read)]
580    pub async fn fetch_matchmaking_stats(&self) -> Result<crate::types::apps::MatchmakingStats, SteamUserError> {
581        let html = self.my_profile_get("gcpd/730/?tab=matchmaking").await?;
582        Ok(parse_matchmaking_html(&html))
583    }
584}
585
586/// Parses the CS2 GCPD matchmaking HTML page into a [`MatchmakingStats`]
587/// struct.
588///
589/// Accepts raw HTML (as fetched from `gcpd/730/?tab=matchmaking` or saved
590/// locally).
591pub fn parse_matchmaking_html(html: &str) -> crate::types::apps::MatchmakingStats {
592    let document = Html::parse_document(html);
593
594    let mut matchmaking_cooldown = None;
595    let mut matchmaking_summary = Vec::new();
596    let mut matchmaking_per_map = Vec::new();
597    let mut last_played_modes = None;
598
599    for table in document.select(sel_kv_table()) {
600        let mut rows = table.select(sel_tr());
601        if let Some(header_row) = rows.next() {
602            let headers: Vec<String> = header_row.select(sel_th()).map(|h| h.text().collect::<String>().trim().to_lowercase()).collect();
603
604            if headers == ["competitive cooldown expiration", "competitive cooldown level", "acknowledged"] {
605                // Table 1: matchmaking_cooldown — Expiration | Level | Acknowledged
606                let mut list = Vec::new();
607                for tr in rows {
608                    let cells: Vec<String> = tr.select(sel_td()).map(|c| c.text().collect::<String>().trim().to_string()).collect();
609                    if cells.len() >= 3 {
610                        list.push(crate::types::apps::CooldownInfo {
611                            competitive_cooldown_expiration: cells.first().map(|s| {
612                                if s.eq_ignore_ascii_case("never") || s.is_empty() {
613                                    crate::types::apps::CooldownExpiration::Never
614                                } else {
615                                    let clean = s.replace("GMT", "");
616                                    chrono::NaiveDateTime::parse_from_str(clean.trim(), "%Y-%m-%d %H:%M:%S").map(|dt| crate::types::apps::CooldownExpiration::At(dt.and_utc())).unwrap_or(crate::types::apps::CooldownExpiration::Never)
617                                }
618                            }),
619                            competitive_cooldown_level: cells.get(1).and_then(|s| s.parse().ok()),
620                            acknowledged: cells.get(2).is_some_and(|s| s.eq_ignore_ascii_case("yes")),
621                        });
622                    }
623                }
624                if !list.is_empty() {
625                    matchmaking_cooldown = Some(list);
626                }
627            } else if headers == ["matchmaking mode", "map", "wins", "ties", "losses", "skill group", "last match", "region"] {
628                // Table 3: matchmaking_per_map — Mode | Map | Wins | Ties | Losses | Skill
629                // Group | Last Match | Region
630                for tr in rows {
631                    let cells: Vec<String> = tr.select(sel_td()).map(|c| c.text().collect::<String>().trim().to_string()).collect();
632                    if cells.len() >= 2 {
633                        matchmaking_per_map.push(crate::types::apps::MatchmakingPerMap {
634                            matchmaking_mode: cells.first().cloned(),
635                            map: cells.get(1).cloned(),
636                            wins: cells.get(2).and_then(|s| s.parse().ok()),
637                            ties: cells.get(3).and_then(|s| s.parse().ok()),
638                            losses: cells.get(4).and_then(|s| s.parse().ok()),
639                            skill_group: cells.get(5).cloned(),
640                            last_match: cells.get(6).cloned(),
641                            region: cells.get(7).and_then(|s| s.parse().ok()),
642                        });
643                    }
644                }
645            } else if headers == ["matchmaking mode", "wins", "ties", "losses", "skill group", "last match", "region"] {
646                // Table 2: matchmaking_summary — Mode | Wins | Ties | Losses | Skill Group |
647                // Last Match | Region
648                for tr in rows {
649                    let cells: Vec<String> = tr.select(sel_td()).map(|c| c.text().collect::<String>().trim().to_string()).collect();
650                    if cells.len() >= 2 {
651                        matchmaking_summary.push(crate::types::apps::MatchmakingSummary {
652                            matchmaking_mode: cells.first().cloned(),
653                            wins: cells.get(1).and_then(|s| s.parse().ok()),
654                            ties: cells.get(2).and_then(|s| s.parse().ok()),
655                            losses: cells.get(3).and_then(|s| s.parse().ok()),
656                            skill_group: cells.get(4).cloned(),
657                            last_match: cells.get(5).cloned(),
658                            region: cells.get(6).and_then(|s| s.parse().ok()),
659                        });
660                    }
661                }
662            } else if headers == ["matchmaking mode", "last match"] {
663                // Table 4: last_played_modes — Mode | Last Match
664                let mut list = Vec::new();
665                for tr in rows {
666                    let cells: Vec<String> = tr.select(sel_td()).map(|c| c.text().collect::<String>().trim().to_string()).collect();
667                    if cells.len() >= 2 {
668                        list.push(crate::types::apps::LastPlayedMode { matchmaking_mode: cells.first().cloned(), last_match: cells.get(1).cloned() });
669                    }
670                }
671                if !list.is_empty() {
672                    last_played_modes = Some(list);
673                }
674            }
675        }
676    }
677
678    crate::types::apps::MatchmakingStats { matchmaking_cooldown, matchmaking_summary, matchmaking_per_map, last_played_modes }
679}
680
681impl SteamUser {
682    /// Fetches eligible event apps from the Steam Points Shop.
683    ///
684    /// Scrapes `https://store.steampowered.com/points/shop/c/events` and parses
685    /// the loyalty store data to find event apps.
686    /// This is a static method that doesn't require authentication.
687    ///
688    /// # Returns
689    ///
690    /// Returns a `Vec<EligibleEventApp>` containing eligible event apps.
691    #[steam_endpoint(GET, host = Store, path = "/points/shop/c/events", kind = Read)]
692    pub async fn get_eligible_event_apps() -> Result<Vec<crate::types::apps::EligibleEventApp>, SteamUserError> {
693        let client = build_adhoc_client()?;
694        let response = client.get("https://store.steampowered.com/points/shop/c/events").send().await?;
695
696        if !response.status().is_success() {
697            return Err(SteamUserError::HttpStatus {
698                status: response.status().as_u16(),
699                url: response.url().to_string(),
700            });
701        }
702
703        let html = response.text().await?;
704        let document = Html::parse_document(&html);
705
706        let loyalty_str = document.select(sel_app_config()).next().and_then(|e| e.value().attr("data-loyaltystore")).ok_or_else(|| SteamUserError::MalformedResponse("data-loyaltystore not found".into()))?;
707
708        #[derive(serde::Deserialize)]
709        struct LoyaltyStore {
710            eligible_apps: Option<EligibleApps>,
711        }
712        #[derive(serde::Deserialize)]
713        struct EligibleApps {
714            apps: Vec<crate::types::apps::EligibleEventApp>,
715        }
716
717        let loyalty_obj: LoyaltyStore = serde_json::from_str(loyalty_str).map_err(|e| SteamUserError::MalformedResponse(format!("Failed to parse loyalty store: {}", e)))?;
718
719        let apps = loyalty_obj.eligible_apps.map(|ea| ea.apps.into_iter().filter(|a| a.event_app).collect()).unwrap_or_default();
720
721        Ok(apps)
722    }
723
724    /// Fetches Steam app information using the Community GetApps API.
725    ///
726    /// Uses the protobuf endpoint at `https://api.steampowered.com/ICommunityService/GetApps/v1`.
727    ///
728    /// # Arguments
729    ///
730    /// * `app_ids` - A slice of Steam App IDs to fetch.
731    ///
732    /// # Returns
733    ///
734    /// Returns the protobuf response containing app information.
735    #[steam_endpoint(POST, host = Api, path = "/ICommunityService/GetApps/v1", kind = Read)]
736    pub async fn get_community_apps(&self, app_ids: &[u32]) -> Result<steam_protos::messages::community::CCommunityGetAppsResponse, SteamUserError> {
737        use prost::Message;
738        use steam_protos::messages::community::CCommunityGetAppsRequest;
739
740        if app_ids.is_empty() {
741            return Ok(steam_protos::messages::community::CCommunityGetAppsResponse { apps: Vec::new() });
742        }
743
744        let request = CCommunityGetAppsRequest { appids: app_ids.to_vec(), language: Some(0) };
745
746        let mut body = Vec::new();
747        request.encode(&mut body)?;
748
749        let params = [("origin", "https://store.steampowered.com"), ("input_protobuf_encoded", &base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &body))];
750
751        let response = self.get_path("/ICommunityService/GetApps/v1").query(&params).send().await?;
752
753        if !response.status().is_success() {
754            return Err(SteamUserError::HttpStatus {
755                status: response.status().as_u16(),
756                url: response.url().to_string(),
757            });
758        }
759
760        let bytes = response.bytes().await?;
761        let response_proto = steam_protos::messages::community::CCommunityGetAppsResponse::decode(bytes)?;
762
763        Ok(response_proto)
764    }
765
766    /// Retrieves Steam Store items for given app IDs.
767    ///
768    /// Uses the protobuf endpoint at `https://api.steampowered.com/IStoreBrowseService/GetItems/v1`.
769    ///
770    /// # Arguments
771    ///
772    /// * `app_ids` - A slice of Steam App IDs to fetch store items for.
773    ///
774    /// # Returns
775    ///
776    /// Returns the protobuf response containing store item information.
777    #[steam_endpoint(POST, host = Api, path = "/IStoreBrowseService/GetItems/v1", kind = Read)]
778    pub async fn get_steam_store_items(&self, app_ids: &[u32]) -> Result<steam_protos::messages::store::CStoreBrowseGetItemsResponse, SteamUserError> {
779        use prost::Message;
780        use steam_protos::messages::store::{
781            c_store_browse_get_items_request::{StoreBrowseContext, StoreBrowseItemDataRequest, StoreItemId},
782            CStoreBrowseGetItemsRequest,
783        };
784
785        if app_ids.is_empty() {
786            return Ok(steam_protos::messages::store::CStoreBrowseGetItemsResponse { store_items: Vec::new() });
787        }
788
789        let request = CStoreBrowseGetItemsRequest {
790            ids: app_ids.iter().map(|&appid| StoreItemId { appid: Some(appid), packageid: None, bundleid: None, tagid: None, creatorid: None, hubcategoryid: None }).collect(),
791            context: Some(StoreBrowseContext {
792                language: Some("english".to_string()),
793                elanguage: None,
794                country_code: Some("VN".to_string()),
795                steam_realm: Some(1),
796            }),
797            data_request: Some(StoreBrowseItemDataRequest {
798                include_assets: Some(true),
799                include_release: None,
800                include_platforms: None,
801                include_all_purchase_options: None,
802                include_screenshots: None,
803                include_trailers: None,
804                include_ratings: None,
805                include_tag_count: None,
806                include_reviews: None,
807                include_basic_info: None,
808                include_supported_languages: None,
809                include_full_description: None,
810                include_included_items: None,
811                included_item_data_request: None,
812                include_assets_without_overrides: None,
813                apply_user_filters: None,
814                include_links: None,
815            }),
816        };
817
818        let mut body = Vec::new();
819        request.encode(&mut body)?;
820
821        let params = [("origin", "https://store.steampowered.com"), ("input_protobuf_encoded", &base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &body))];
822
823        let response = self.get_path("/IStoreBrowseService/GetItems/v1").query(&params).send().await?;
824
825        if !response.status().is_success() {
826            return Err(SteamUserError::HttpStatus {
827                status: response.status().as_u16(),
828                url: response.url().to_string(),
829            });
830        }
831
832        let bytes = response.bytes().await?;
833        let response_proto = steam_protos::messages::store::CStoreBrowseGetItemsResponse::decode(bytes)?;
834
835        Ok(response_proto)
836    }
837
838    /// Checks which friends own a specific package (useful for gifting
839    /// validation).
840    ///
841    /// Uses the `ICheckoutService/GetFriendOwnershipForGifting/v1` endpoint.
842    ///
843    /// # Arguments
844    ///
845    /// * `access_token` - The OAuth access token to use.
846    /// * `package_id` - The Steam Package ID to check (e.g. 54029 for CS:GO
847    ///   Prime).
848    ///
849    /// # Returns
850    ///
851    /// Returns a `FriendOwnershipResponse` containing ownership info for
852    /// friends.
853    #[steam_endpoint(POST, host = Api, path = "/ICheckoutService/GetFriendOwnershipForGifting/v1", kind = Read)]
854    pub async fn get_friend_ownership_for_gifting(&self, access_token: &str, package_id: u32) -> Result<crate::types::apps::FriendOwnershipResponse, SteamUserError> {
855        use prost::Message;
856
857        #[derive(Clone, PartialEq, ::prost::Message)]
858        pub struct OwnershipItemIdProto {
859            #[prost(uint32, optional, tag = "1")]
860            pub appid: Option<u32>,
861            #[prost(uint32, optional, tag = "2")]
862            pub packageid: Option<u32>,
863            #[prost(uint32, optional, tag = "3")]
864            pub bundleid: Option<u32>,
865            #[prost(uint32, optional, tag = "4")]
866            pub tagid: Option<u32>,
867            #[prost(uint32, optional, tag = "5")]
868            pub creatorid: Option<u32>,
869            #[prost(uint32, optional, tag = "6")]
870            pub hubcategoryid: Option<u32>,
871        }
872
873        #[derive(Clone, PartialEq, ::prost::Message)]
874        pub struct CCheckoutGetFriendOwnershipForGiftingRequest {
875            #[prost(message, repeated, tag = "1")]
876            pub item_ids: ::prost::alloc::vec::Vec<OwnershipItemIdProto>,
877        }
878
879        #[derive(Clone, PartialEq, ::prost::Message)]
880        pub struct FriendOwnershipProto {
881            #[prost(uint32, repeated, tag = "1")]
882            pub partial_owns_appids: ::prost::alloc::vec::Vec<u32>,
883            #[prost(uint32, repeated, tag = "2")]
884            pub partial_wishes_for: ::prost::alloc::vec::Vec<u32>,
885            #[prost(uint32, tag = "3")]
886            pub accountid: u32,
887            #[prost(bool, tag = "4")]
888            pub already_owns: bool,
889            #[prost(bool, tag = "5")]
890            pub wishes_for: bool,
891        }
892
893        #[derive(Clone, PartialEq, ::prost::Message)]
894        pub struct FriendOwnershipInfoProto {
895            #[prost(message, repeated, tag = "1")]
896            pub friend_ownership: ::prost::alloc::vec::Vec<FriendOwnershipProto>,
897            #[prost(message, optional, tag = "2")]
898            pub item_id: Option<OwnershipItemIdProto>,
899        }
900
901        #[derive(Clone, PartialEq, ::prost::Message)]
902        pub struct CCheckoutGetFriendOwnershipForGiftingResponse {
903            #[prost(message, repeated, tag = "1")]
904            pub ownership_info: ::prost::alloc::vec::Vec<FriendOwnershipInfoProto>,
905        }
906
907        let item_id = OwnershipItemIdProto { packageid: Some(package_id), appid: None, bundleid: None, tagid: None, creatorid: None, hubcategoryid: None };
908
909        let request = CCheckoutGetFriendOwnershipForGiftingRequest { item_ids: vec![item_id] };
910
911        let mut body = Vec::new();
912        request.encode(&mut body)?;
913
914        let params = [("access_token", access_token), ("spoof_steamid", ""), ("origin", "https://store.steampowered.com"), ("input_protobuf_encoded", &base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &body))];
915
916        let response = self.get_path("/ICheckoutService/GetFriendOwnershipForGifting/v1").query(&params).send().await?;
917
918        if !response.status().is_success() {
919            return Err(SteamUserError::HttpStatus {
920                status: response.status().as_u16(),
921                url: response.url().to_string(),
922            });
923        }
924
925        let bytes = response.bytes().await?;
926        let response_proto = CCheckoutGetFriendOwnershipForGiftingResponse::decode(bytes)?;
927
928        let ownership_info = response_proto
929            .ownership_info
930            .into_iter()
931            .map(|info| crate::types::apps::FriendOwnershipInfo {
932                friend_ownership: info
933                    .friend_ownership
934                    .into_iter()
935                    .map(|fo| crate::types::apps::FriendOwnership {
936                        partial_owns_appids: fo.partial_owns_appids,
937                        partial_wishes_for: fo.partial_wishes_for,
938                        accountid: fo.accountid,
939                        already_owns: fo.already_owns,
940                        wishes_for: fo.wishes_for,
941                    })
942                    .collect(),
943                item_id: info.item_id.map(|id| crate::types::apps::OwnershipItemId {
944                    appid: id.appid,
945                    packageid: id.packageid,
946                    bundleid: id.bundleid,
947                    tagid: id.tagid,
948                    creatorid: id.creatorid,
949                    hubcategoryid: id.hubcategoryid,
950                }),
951            })
952            .collect();
953
954        Ok(crate::types::apps::FriendOwnershipResponse { ownership_info })
955    }
956}