Skip to main content

steam_user/services/
inventory.rs

1//! Inventory services.
2
3use steamid::SteamID;
4
5use crate::{
6    client::SteamUser,
7    endpoint::steam_endpoint,
8    error::SteamUserError,
9    types::{ActiveInventory, AppId, ContextId, EconItem, InventoryCursor, InventoryHistoryItem, InventoryHistoryResult, InventoryResponse, PriceOverview, TradePeople},
10};
11
12impl SteamUser {
13    /// Retrieves the contents of a specific user's inventory.
14    ///
15    /// This method handles pagination automatically and fetches all items for
16    /// the given application and context.
17    ///
18    /// # Arguments
19    ///
20    /// * `user_id` - The [`SteamID`] of the inventory owner.
21    /// * `appid` - The Steam App ID (e.g., 730 for CS:GO, 440 for TF2).
22    /// * `context_id` - The inventory context ID (usually 2).
23    ///
24    /// # Returns
25    ///
26    /// Returns a `Vec<EconItem>` containing all items in the specified
27    /// inventory.
28    #[steam_endpoint(GET, host = Community, path = "/inventory/{user_id}/{app_id}/{context_id}", kind = Read)]
29    pub async fn get_user_inventory_contents(&self, user_id: SteamID, appid: AppId, context_id: ContextId) -> Result<Vec<EconItem>, SteamUserError> {
30        let path = format!("/inventory/{}/{}/{}", user_id.steam_id64(), appid, context_id);
31
32        let mut items = Vec::new();
33        let mut start_assetid = None;
34
35        loop {
36            let mut request = self.get_path(&path).query(&[("count", "2000"), ("preserve_bbcode", "1"), ("raw_asset_properties", "1")]);
37
38            if let Some(start) = start_assetid.as_ref() {
39                request = request.query(&[("start_assetid", start)]);
40            }
41
42            let response: InventoryResponse = request.send().await?.error_for_status()?.json().await?;
43
44            tracing::info!(
45                path = %path,
46                success = response.success,
47                total_count = ?response.total_inventory_count,
48                assets = response.assets.len(),
49                descriptions = response.descriptions.len(),
50                "inventory fetch result",
51            );
52
53            if response.success != 1 {
54                tracing::warn!(success = response.success, "inventory fetch: Steam returned non-1 success, returning error");
55                return Err(SteamUserError::from_eresult(response.success));
56            }
57
58            // Build description lookup by classid_instanceid
59            let desc_map: std::collections::HashMap<String, std::sync::Arc<crate::types::InventoryDescription>> = response.descriptions.into_iter().map(|d| (format!("{}_{}", d.classid, d.instanceid), std::sync::Arc::new(d))).collect();
60
61            for asset in &response.assets {
62                let key = format!("{}_{}", asset.classid, asset.instanceid);
63                if let Some(desc) = desc_map.get(&key) {
64                    match EconItem::try_from_inventory_data(asset, desc.clone()) {
65                        Ok(mut item) => {
66                            if let Some(steam_id) = self.steam_id() {
67                                item.owner_steam_id = Some(steam_id);
68                            }
69                            items.push(item);
70                        }
71                        Err(e) => {
72                            tracing::warn!(assetid = %asset.assetid, classid = %asset.classid, error = %e, "skipping malformed inventory asset");
73                        }
74                    }
75                }
76            }
77
78            // Check for pagination
79            if response.more_items {
80                if let Some(last) = response.last_assetid {
81                    start_assetid = Some(last);
82                    continue;
83                }
84            }
85
86            break;
87        }
88
89        Ok(items)
90    }
91
92    /// Retrieves the inventory of the currently authenticated user.
93    ///
94    /// Convenience method that calls [`Self::get_user_inventory_contents`] for
95    /// the current session.
96    // delegates to `get_user_inventory_contents` — no #[steam_endpoint]
97    #[tracing::instrument(skip(self), fields(app_id = appid.get(), context_id = context_id.get()))]
98    pub async fn get_inventory(&self, appid: AppId, context_id: ContextId) -> Result<Vec<EconItem>, SteamUserError> {
99        let steam_id = self.steam_id().ok_or(SteamUserError::NotLoggedIn)?;
100        self.get_user_inventory_contents(steam_id, appid, context_id).await
101    }
102
103    /// Retrieves the authenticated user's inventory in the legacy JSON format
104    /// used by the trading UI.
105    ///
106    /// This format includes the `rgInventory` and `rgDescriptions` structures.
107    #[steam_endpoint(GET, host = Community, path = "/my/inventory/json/{app_id}/{context_id}", kind = Read)]
108    pub async fn get_inventory_trading(&self, appid: AppId, context_id: ContextId) -> Result<serde_json::Value, SteamUserError> {
109        let response: serde_json::Value = self.get_path(format!("/my/inventory/json/{}/{}", appid, context_id)).query(&[("trading", "1")]).send().await?.json().await?;
110
111        if response.get("success").and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|i| i == 1))).unwrap_or(false) {
112            let mut enriched = response;
113            let steam_id = self.steam_id().map(|id| id.steam_id64().to_string()).unwrap_or_default();
114            let context_str = context_id.to_string();
115
116            if let Some(obj) = enriched.as_object_mut() {
117                if let Some(rg_inv) = obj.get_mut("rgInventory").and_then(|v| v.as_object_mut()) {
118                    for (_, item) in rg_inv {
119                        if let Some(item_obj) = item.as_object_mut() {
120                            item_obj.insert("steamId".to_string(), serde_json::json!(steam_id));
121                            item_obj.insert("contextId".to_string(), serde_json::json!(context_str));
122                        }
123                    }
124                }
125                if let Some(rg_desc) = obj.get_mut("rgDescriptions").and_then(|v| v.as_object_mut()) {
126                    for (_, desc) in rg_desc {
127                        if let Some(desc_obj) = desc.as_object_mut() {
128                            desc_obj.insert("steamId".to_string(), serde_json::json!(steam_id));
129                            desc_obj.insert("contextId".to_string(), serde_json::json!(context_str));
130                        }
131                    }
132                }
133            }
134            Ok(enriched)
135        } else {
136            Err(SteamUserError::MalformedResponse("Failed to fetch trading inventory".into()))
137        }
138    }
139
140    /// Retrieves current price statistics for a specific market item.
141    ///
142    /// # Arguments
143    ///
144    /// * `market_hash_name` - The unique name used on the Steam Community
145    ///   Market (e.g., "Clutch Case").
146    #[steam_endpoint(GET, host = Community, path = "/market/priceoverview/", kind = Read)]
147    pub async fn get_price_overview(&self, appid: AppId, market_hash_name: &str) -> Result<PriceOverview, SteamUserError> {
148        let appid_str = appid.to_string();
149        let response: PriceOverview = self
150            .get_path("/market/priceoverview/")
151            .query(&[
152                ("appid", &appid_str),
153                ("market_hash_name", &market_hash_name.to_string()),
154                ("currency", &"15".to_string()), // Default to VNĐ
155            ])
156            .send()
157            .await?
158            .json()
159            .await?;
160
161        Ok(response)
162    }
163
164    /// Normalize trade history description.
165    fn normalize_trade_description(description: &str) -> String {
166        const DESCRIPTION_LIST: &[&str] = &[
167            "You purchased an item on the Community Market.",
168            "You listed an item on the Community Market.",
169            "You canceled a listing on the Community Market. The item was returned to you.",
170            "Crafted",
171            "Expired",
172            "Earned a new rank and got a drop",
173            "Got an item drop",
174            "Random item drop",
175            "Purchased a gift",
176            "Earned by redeeming Steam Points",
177            "Earned by completing your Store Discovery Queue",
178            "Earned",
179            "Traded",
180            "Earned due to game play time",
181            "Listed on the Steam Community Market",
182            "Turned into Gems",
183            "Unpacked a booster pack",
184            "Purchased with Gems",
185            "Unpacked Gems from Sack",
186            "Earned by crafting",
187            "Used",
188            "Unsealed",
189            "Earned by sale purchases",
190            "Unlocked a container",
191            "Purchased from the store",
192            "You deleted",
193            "Found",
194            "Received from the Community Market",
195            "Exchanged one or more items for something different",
196            "Earned an item due to ownership of another game",
197            "Sticker applied",
198            "Sticker removed",
199            "Subscription/Seasonal Item Grant",
200            "Earned from unlocking an achievement",
201            "Moved to Storage Unit",
202        ];
203
204        if DESCRIPTION_LIST.contains(&description) {
205            return description.to_string();
206        }
207
208        if description.starts_with("You traded with ") {
209            return "You traded with".to_string();
210        }
211        if description.starts_with("Gift sent to and redeemed by ") {
212            return "Gift sent to and redeemed by".to_string();
213        }
214        if description.starts_with("Your trade with ") && description.ends_with(" was on hold, and the trade has now completed.") {
215            return "Your trade with friend was on hold, and the trade has now completed.".to_string();
216        }
217        if description.starts_with("You listed an item on the Community Market. The listing was placed on hold until") {
218            return "You listed an item on the Community Market. The listing was placed on hold until".to_string();
219        }
220        if description.starts_with("Earned in ") {
221            return "Earned in game".to_string();
222        }
223        if description.starts_with("Refunded a gift because the recipient,") && description.ends_with("declined") {
224            return "Refunded a gift because the recipient declined".to_string();
225        }
226        if description.starts_with("Your held trade with") && description.ends_with("was canceled. The items have been returned to you.") {
227            return "Your held trade with person was canceled. The items have been returned to you.".to_string();
228        }
229
230        description.to_string()
231    }
232
233    /// Retrieves a trading partner's inventory in the legacy trading JSON
234    /// format.
235    #[steam_endpoint(GET, host = Community, path = "/tradeoffer/new/partnerinventory/", kind = Read)]
236    pub async fn get_inventory_trading_partner(&self, appid: AppId, partner: SteamID, context_id: ContextId) -> Result<serde_json::Value, SteamUserError> {
237        let appid_str = appid.to_string();
238        let partner_str = partner.steam_id64().to_string();
239        let context_str = context_id.to_string();
240
241        let response: serde_json::Value = self.get_path("/tradeoffer/new/partnerinventory/").query(&[("partner", partner_str.as_str()), ("appid", appid_str.as_str()), ("contextid", context_str.as_str())]).header("Referer", "https://steamcommunity.com/tradeoffer/").send().await?.json().await?;
242
243        if response.get("success").and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|i| i == 1))).unwrap_or(false) {
244            let mut enriched = response;
245            let partner_str = partner.steam_id64().to_string();
246            let context_str = context_id.to_string();
247
248            if let Some(obj) = enriched.as_object_mut() {
249                if let Some(rg_inv) = obj.get_mut("rgInventory").and_then(|v| v.as_object_mut()) {
250                    for (_, item) in rg_inv {
251                        if let Some(item_obj) = item.as_object_mut() {
252                            item_obj.insert("steamId".to_string(), serde_json::json!(partner_str));
253                            item_obj.insert("contextId".to_string(), serde_json::json!(context_str));
254                        }
255                    }
256                }
257                if let Some(rg_desc) = obj.get_mut("rgDescriptions").and_then(|v| v.as_object_mut()) {
258                    for (_, desc) in rg_desc {
259                        if let Some(desc_obj) = desc.as_object_mut() {
260                            desc_obj.insert("steamId".to_string(), serde_json::json!(partner_str));
261                            desc_obj.insert("contextId".to_string(), serde_json::json!(context_str));
262                        }
263                    }
264                }
265            }
266            Ok(enriched)
267        } else {
268            Err(SteamUserError::MalformedResponse("Failed to fetch trading partner inventory".into()))
269        }
270    }
271
272    /// Parses an inventory history date string like "24 Mar, 2023" + "3:49pm"
273    /// into a Unix timestamp (UTC seconds). Returns 0 on failure.
274    fn parse_inventory_history_date(date_text: &str, time_text: &str) -> u64 {
275        use chrono::NaiveDateTime;
276
277        let combined = format!("{} {}", date_text, time_text);
278        // Try format: "24 Mar, 2023 3:49pm" (English Steam format)
279        if let Ok(dt) = NaiveDateTime::parse_from_str(&combined, "%d %b, %Y %l:%M%P") {
280            return u64::try_from(dt.and_utc().timestamp()).unwrap_or(0);
281        }
282        // Try alternative: "24 Mar, 2023 3:49 pm"
283        if let Ok(dt) = NaiveDateTime::parse_from_str(&combined, "%d %b, %Y %l:%M %P") {
284            return u64::try_from(dt.and_utc().timestamp()).unwrap_or(0);
285        }
286        // Try 24h format: "24 Mar, 2023 15:49"
287        if let Ok(dt) = NaiveDateTime::parse_from_str(&combined, "%d %b, %Y %H:%M") {
288            return u64::try_from(dt.and_utc().timestamp()).unwrap_or(0);
289        }
290        tracing::warn!(combined = %combined, "failed to parse inventory history date");
291        0
292    }
293
294    /// Retrieves the inventory history for the authenticated user.
295    ///
296    /// This includes detailed records of items added or removed via trades,
297    /// drops, market actions, etc.
298    ///
299    /// # Arguments
300    ///
301    /// * `cursor` - Optional [`InventoryCursor`] for paginated results. Use
302    ///   `None` for the first page.
303    #[steam_endpoint(GET, host = Community, path = "/my/inventoryhistory/", kind = Read)]
304    pub async fn get_inventory_history(&self, cursor: Option<InventoryCursor>) -> Result<InventoryHistoryResult, SteamUserError> {
305        let mut query = vec![("ajax", "1"), ("l", "english")];
306
307        let cursor = cursor.unwrap_or_default();
308        let cursor_s = cursor.s.to_string();
309        let cursor_time_frac = cursor.time_frac.to_string();
310        let cursor_time = cursor.time.to_string();
311
312        query.push(("cursor[s]", cursor_s.as_str()));
313        query.push(("cursor[time_frac]", cursor_time_frac.as_str()));
314        query.push(("cursor[time]", cursor_time.as_str()));
315
316        let http_response = self.get_path("/my/inventoryhistory/").query(&query).send().await?;
317
318        // A dead session makes Steam 302 `/my/inventoryhistory/` to the login
319        // page; reqwest auto-follows that to a 200 `text/html` "Sign In" page.
320        // Without this check the downstream `.json()` fails with the opaque
321        // "error decoding response body", hiding the real cause (expired
322        // cookies). `check_response` recognizes the `/login` landing and returns
323        // `NotLoggedIn` so callers can re-login instead of retrying forever.
324        self.check_response(&http_response)?;
325
326        // Read the body as text first so a non-JSON response (e.g. an HTML
327        // error/maintenance page that slipped past `check_response`) surfaces a
328        // diagnostic snippet instead of a contextless parse error.
329        let status = http_response.status();
330        let body = http_response.text().await?;
331        let response: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
332            let snippet: String = body.chars().take(200).collect();
333            SteamUserError::MalformedResponse(format!("inventory history: response was not JSON (HTTP {status}): {e}; body[0..200]={snippet:?}"))
334        })?;
335
336        if !response.get("success").and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|i| i == 1))).unwrap_or(false) {
337            return Err(SteamUserError::MalformedResponse("Failed to fetch inventory history".into()));
338        }
339
340        let html = response.get("html").and_then(|v| v.as_str()).unwrap_or("").replace(['\t', '\n', '\r'], "");
341
342        let descriptions = response.get("descriptions").cloned();
343
344        // Extract the pagination cursor up front so the heavy HTML parse can
345        // run on the blocking pool without needing `response` afterwards.
346        let next_cursor = response.get("cursor").and_then(|v| {
347            Some(InventoryCursor {
348                time: v.get("time")?.as_u64()?,
349                time_frac: u32::try_from(v.get("time_frac")?.as_u64()?).ok()?,
350                s: u32::try_from(v.get("s")?.as_u64()?).ok()?,
351            })
352        });
353
354        let steamid = self.steam_id();
355
356        // Inventory history pages can be several megabytes; the parse + CSS
357        // selection is CPU-bound and would block the runtime if run inline.
358        let trade_history = tokio::task::spawn_blocking(move || parse_inventory_history_rows(&html, descriptions.as_ref(), steamid)).await.map_err(|e| SteamUserError::Other(format!("inventory-history parse task failed: {e}")))??;
359
360        Ok(InventoryHistoryResult { cursor: next_cursor, trade_history })
361    }
362
363    /// Fetches the entire inventory history by automatically iterating through
364    /// all pages.
365    ///
366    /// WARNING: This can make many requests for accounts with extensive
367    /// history.
368    // paginates `get_inventory_history` — no #[steam_endpoint]
369    #[tracing::instrument(skip(self))]
370    pub async fn get_full_inventory_history(&self) -> Result<Vec<InventoryHistoryItem>, SteamUserError> {
371        let mut trade_history = Vec::new();
372        let mut cursor = None;
373
374        loop {
375            let result = self.get_inventory_history(cursor).await?;
376            trade_history.extend(result.trade_history);
377
378            if result.cursor.is_none() {
379                break;
380            }
381            cursor = result.cursor;
382        }
383
384        Ok(trade_history)
385    }
386
387    /// Retrieves a list of apps that have items in the authenticated user's
388    /// inventory.
389    ///
390    /// This method parses the user's inventory page to extract information
391    /// about which games have items and how many items are in each
392    /// inventory.
393    ///
394    /// # Returns
395    ///
396    /// Returns a `Vec<ActiveInventory>` containing information about each app
397    /// with inventory items, including the app ID, game icon, game name,
398    /// and item count.
399    #[steam_endpoint(GET, host = Community, path = "/my/inventory", kind = Read)]
400    pub async fn get_active_inventories(&self) -> Result<Vec<ActiveInventory>, SteamUserError> {
401        let html = self.get_with_manual_redirects("https://steamcommunity.com/my/inventory").await?;
402
403        // Inventory HTML can be large; parse and traverse on the blocking pool.
404        tokio::task::spawn_blocking(move || parse_active_inventories(&html)).await.map_err(|e| SteamUserError::Other(format!("active-inventories parse task failed: {e}")))?
405    }
406}
407
408/// Parses the rows of an inventory-history HTML response into typed
409/// [`InventoryHistoryItem`]s. Extracted as a pure synchronous function so the
410/// caller can run it on `tokio::task::spawn_blocking`.
411fn parse_inventory_history_rows(html: &str, descriptions: Option<&serde_json::Value>, steamid: Option<SteamID>) -> Result<Vec<InventoryHistoryItem>, SteamUserError> {
412    use scraper::{Html, Selector};
413    let document = Html::parse_document(html);
414    let row_selector = Selector::parse(".tradehistoryrow").map_err(|e| SteamUserError::Other(e.to_string()))?;
415    let date_selector = Selector::parse(".tradehistory_date").map_err(|e| SteamUserError::Other(e.to_string()))?;
416    let timestamp_selector = Selector::parse(".tradehistory_timestamp").map_err(|e| SteamUserError::Other(e.to_string()))?;
417    let event_desc_selector = Selector::parse(".tradehistory_event_description").map_err(|e| SteamUserError::Other(e.to_string()))?;
418    let link_selector = Selector::parse("a[href]").map_err(|e| SteamUserError::Other(e.to_string()))?;
419    let plusminus_selector = Selector::parse(".tradehistory_items_plusminus").map_err(|e| SteamUserError::Other(e.to_string()))?;
420    let items_group_selector = Selector::parse(".tradehistory_items").map_err(|e| SteamUserError::Other(e.to_string()))?;
421    let item_selector = Selector::parse(".tradehistory_items_group > .history_item").map_err(|e| SteamUserError::Other(e.to_string()))?;
422
423    let mut trade_history = Vec::new();
424
425    for row in document.select(&row_selector) {
426        let date_el = row.select(&date_selector).next();
427        let timestamp_el = row.select(&timestamp_selector).next();
428
429        let timestamp_text = timestamp_el.map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
430
431        let date_text = date_el
432            .map(|e| {
433                let mut text = e.text().collect::<String>();
434                if !timestamp_text.is_empty() {
435                    text = text.replace(&timestamp_text, "");
436                }
437                text.trim().to_string()
438            })
439            .unwrap_or_default();
440
441        let event_desc_el = row.select(&event_desc_selector).next();
442        let raw_description = event_desc_el.map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
443
444        let description = SteamUser::normalize_trade_description(&raw_description);
445
446        let mut trade_people = None;
447        if let Some(event_el) = event_desc_el {
448            if let Some(link_el) = event_el.select(&link_selector).next() {
449                let href = link_el.value().attr("href").unwrap_or("");
450                if href.contains("steamcommunity.com/profiles/") || href.contains("steamcommunity.com/id/") {
451                    trade_people = Some(TradePeople { name: link_el.text().collect::<String>().trim().to_string(), url: href.to_string() });
452                }
453            }
454        }
455
456        let plusminus = row.select(&plusminus_selector).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
457
458        let mut trade_history_items = Vec::new();
459
460        for items_el in row.select(&items_group_selector) {
461            let item_plusminus = items_el.select(&plusminus_selector).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
462
463            for item_el in items_el.select(&item_selector) {
464                let text = item_el.text().collect::<String>().trim().to_string();
465                if text == "You did not receive any items in this trade." {
466                    continue;
467                }
468
469                let appid = item_el.value().attr("data-appid").unwrap_or("");
470                let classid = item_el.value().attr("data-classid").unwrap_or("");
471                let instanceid = item_el.value().attr("data-instanceid").unwrap_or("0");
472                let context_id = item_el.value().attr("data-contextid").unwrap_or("");
473
474                let mut item_obj = serde_json::json!({
475                    "appid": appid,
476                    "classid": classid,
477                    "instanceid": instanceid,
478                    "contextid": context_id,
479                    "steamid": steamid,
480                    "plusminus": item_plusminus,
481                });
482
483                if let Some(descs) = descriptions {
484                    if let Some(asset_desc) = descs.get(appid).and_then(|a| a.get(format!("{}_{}", classid, instanceid))) {
485                        if let Some(obj) = item_obj.as_object_mut() {
486                            if let Some(desc_obj) = asset_desc.as_object() {
487                                for (k, v) in desc_obj {
488                                    obj.insert(k.clone(), v.clone());
489                                }
490                            }
491                        }
492                    }
493                }
494                trade_history_items.push(item_obj);
495            }
496        }
497
498        if !trade_history_items.is_empty() {
499            let timestamp_str = format!("{} {}", date_text, timestamp_text);
500            let timestamp = SteamUser::parse_inventory_history_date(&date_text, &timestamp_text);
501
502            let items_key: String = trade_history_items
503                .iter()
504                .map(|item| {
505                    let classid = item.get("classid").and_then(|v| v.as_str()).unwrap_or("0");
506                    let instanceid = item.get("instanceid").and_then(|v| v.as_str()).unwrap_or("0");
507                    format!("{}_{}", classid, instanceid)
508                })
509                .collect::<Vec<_>>()
510                .join("|");
511            let id_raw = format!("{}_{}_{}_{}", timestamp, description, plusminus, items_key,);
512            let id: String = id_raw.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '+' | '_' | '|')).collect::<String>().to_lowercase();
513
514            trade_history.push(InventoryHistoryItem { id, timestamp, timestamp_str, description, plusminus, trade_history_items, steamid, trade_people });
515        }
516    }
517
518    Ok(trade_history)
519}
520
521/// Parses the `/my/inventory` page and returns the per-app inventory summary.
522///
523/// Extracted as a pure synchronous function so the caller can dispatch it onto
524/// `tokio::task::spawn_blocking`.
525fn parse_active_inventories(html: &str) -> Result<Vec<ActiveInventory>, SteamUserError> {
526    use scraper::{Html, Selector};
527    let document = Html::parse_document(html);
528
529    let games_list_selector = Selector::parse("#games_list_public").map_err(|e| SteamUserError::Other(e.to_string()))?;
530
531    let Some(games_list) = document.select(&games_list_selector).next() else {
532        // No games list found - either no inventories or private profile
533        return Ok(Vec::new());
534    };
535
536    let tab_selector = Selector::parse(".games_list_tabs > .games_list_tab").map_err(|e| SteamUserError::Other(e.to_string()))?;
537    let icon_selector = Selector::parse(".item_desc_game_icon img").map_err(|e| SteamUserError::Other(e.to_string()))?;
538    let name_selector = Selector::parse(".games_list_tab_name").map_err(|e| SteamUserError::Other(e.to_string()))?;
539    let count_selector = Selector::parse(".games_list_tab_number").map_err(|e| SteamUserError::Other(e.to_string()))?;
540
541    let mut inventories = Vec::new();
542
543    for tab in games_list.select(&tab_selector) {
544        let game_icon = tab.select(&icon_selector).next().and_then(|el| el.value().attr("src")).map(|s| s.to_string());
545
546        let game_name = tab.select(&name_selector).next().map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
547
548        let count = tab
549            .select(&count_selector)
550            .next()
551            .map(|el| {
552                let text = el.text().collect::<String>();
553                let text = text.trim();
554                text.trim_start_matches('(').trim_end_matches(')').parse::<u32>().unwrap_or(0)
555            })
556            .unwrap_or(0);
557
558        let app_id = tab.value().attr("id").and_then(|id| id.strip_prefix("inventory_link_")).and_then(|id| id.parse::<u32>().ok()).unwrap_or(0);
559
560        if app_id > 0 {
561            inventories.push(ActiveInventory { app_id, game_icon, game_name, count });
562        }
563    }
564
565    Ok(inventories)
566}