Skip to main content

steam_user/services/
market.rs

1//! Market operation services.
2
3use std::{collections::HashMap, sync::OnceLock};
4
5use regex::Regex;
6use scraper::{Html, Selector};
7
8use crate::{
9    client::SteamUser,
10    endpoint::steam_endpoint,
11    error::SteamUserError,
12    services::account::parse_wallet_balance,
13    types::{AppId, AssetId, BoosterPackEntry, BoosterResult, EconItem, GemResult, GemValue, ItemNameId, ItemOrdersHistogramResponse, MarketHistoryListing, MarketHistoryResponse, MarketListing, MarketRestrictions, SellItemResult, WalletBalance},
14};
15
16static LISTING_ID_RE: OnceLock<Regex> = OnceLock::new();
17static HOVER_RE: OnceLock<Regex> = OnceLock::new();
18static LISTING_ROW_RE: OnceLock<Regex> = OnceLock::new();
19static BOOSTER_DATA_RE: OnceLock<Regex> = OnceLock::new();
20static ITEM_NAMEID_RE: OnceLock<Regex> = OnceLock::new();
21
22fn get_listing_id_re() -> &'static Regex {
23    LISTING_ID_RE.get_or_init(|| Regex::new(r"history_row_(.*?)_").expect("Invalid regex"))
24}
25
26fn get_hover_re() -> &'static Regex {
27    HOVER_RE.get_or_init(|| Regex::new(r"CreateItemHoverFromContainer\s*\(\s*g_rgAssets\s*,\s*'([^']+)'\s*,\s*(\d+)\s*,\s*'(\d+)'\s*,\s*'(\d+)'\s*,\s*(\d+)\s*\)").expect("Invalid regex"))
28}
29
30fn get_listing_row_re() -> &'static Regex {
31    LISTING_ROW_RE.get_or_init(|| Regex::new(r"history_row_(\d+)_").expect("Invalid regex"))
32}
33
34fn get_booster_data_re() -> &'static Regex {
35    BOOSTER_DATA_RE.get_or_init(|| Regex::new(r"CBoosterCreatorPage\.Init\(\s*\d+\s*,\s*(\[.+?\])\s*,").expect("Invalid regex"))
36}
37
38fn get_item_nameid_re() -> &'static Regex {
39    ITEM_NAMEID_RE.get_or_init(|| Regex::new(r"Market_LoadOrderSpread\(\s*(\d+)\s*\)").expect("Invalid regex"))
40}
41
42/// Normalize whitespace in strings similar to JS StringUtils.cleanSpace
43fn clean_space(text: &str) -> String {
44    text.split_whitespace().collect::<Vec<_>>().join(" ")
45}
46
47/// Extract a hex color from a CSS style string.
48/// e.g. `parse_style_hex_color("border-color: #5e98d9;background-color:
49/// #2f363e;", "border-color")` → `"5e98d9"`
50fn parse_style_hex_color(style: &str, property: &str) -> String {
51    // Try "property: #hex" or "property:#hex"
52    for sep in &[": #", ":#"] {
53        let needle = format!("{}{}", property, sep);
54        if let Some(start) = style.find(&needle) {
55            let rest = &style[start + needle.len()..];
56            let end = rest.find(|c: char| !c.is_ascii_hexdigit()).unwrap_or(rest.len());
57            let hex = &rest[..end];
58            if !hex.is_empty() {
59                return hex.to_string();
60            }
61        }
62    }
63    String::new()
64}
65
66impl SteamUser {
67    /// Retrieves the gem value of a specific Steam inventory item.
68    ///
69    /// # Arguments
70    ///
71    /// * `appid` - The App ID of the item.
72    /// * `assetid` - The unique asset ID of the item in the user's inventory.
73    #[steam_endpoint(GET, host = Community, path = "/auction/ajaxgetgoovalue/", kind = Read)]
74    pub async fn get_gem_value(&self, appid: AppId, assetid: AssetId) -> Result<GemValue, SteamUserError> {
75        let response: serde_json::Value = self.post_path("/auction/ajaxgetgoovalue/").form(&[("appid", appid.to_string().as_str()), ("contextid", "6"), ("assetid", assetid.to_string().as_str())]).send().await?.json().await?;
76
77        Self::check_json_success(&response, "Failed to get gem value")?;
78
79        let prompt_title = response.get("strTitle").and_then(|v| v.as_str()).unwrap_or("").to_string();
80        let gem_value = response.get("goo_value").and_then(|v| v.as_str()).and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
81
82        Ok(GemValue { prompt_title, gem_value })
83    }
84
85    /// Converts a Steam inventory item into gems (Goo).
86    ///
87    /// # Arguments
88    ///
89    /// * `appid` - The App ID of the item.
90    /// * `assetid` - The unique asset ID of the item.
91    /// * `expected_value` - The expected gem value (usually obtained from
92    ///   [`Self::get_gem_value`]).
93    #[steam_endpoint(POST, host = Community, path = "/auction/ajaxgrindintogoo/", kind = Write)]
94    pub async fn turn_item_into_gems(&self, appid: AppId, assetid: AssetId, expected_value: u32) -> Result<GemResult, SteamUserError> {
95        let response: serde_json::Value = self.post_path("/auction/ajaxgrindintogoo/").form(&[("appid", appid.to_string().as_str()), ("contextid", "6"), ("assetid", assetid.to_string().as_str()), ("goo_value_expected", expected_value.to_string().as_str())]).send().await?.json().await?;
96
97        Self::check_json_success(&response, "Failed to turn item into gems")?;
98
99        let gems_received = response.get("goo_value_received ").and_then(|v| v.as_str()).and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
100
101        let total_gems = u32::try_from(response.get("goo_value_total").and_then(|v| v.as_i64()).unwrap_or(0)).unwrap_or(u32::MAX);
102
103        Ok(GemResult { gems_received, total_gems })
104    }
105
106    /// Unpacks a Steam booster pack.
107    ///
108    /// # Arguments
109    ///
110    /// * `appid` - The App ID of the game for the booster pack.
111    /// * `assetid` - The unique asset ID of the booster pack in your inventory.
112    #[steam_endpoint(POST, host = Community, path = "/auction/ajaxunpackbooster/", kind = Write)]
113    pub async fn open_booster_pack(&self, appid: AppId, assetid: AssetId) -> Result<Vec<EconItem>, SteamUserError> {
114        let response: serde_json::Value = self.post_path("/auction/ajaxunpackbooster/").form(&[("appid", appid.to_string().as_str()), ("communityitemid", assetid.to_string().as_str())]).send().await?.json().await?;
115
116        Self::check_json_success(&response, "Failed to open booster pack")?;
117
118        // Response contains items array, but usually detailed item processing is needed
119        // For simplicity we return basic info if available, or just success
120        // Note: The response field name for items depends on API version somewhat
121
122        // This part needs more detailed EconItem construction similar to inventory
123        // For now, let's assume it is successful and return empty list until we
124        // implement full item parsing from this specific endpoint specific
125        // format
126
127        Ok(vec![])
128    }
129
130    /// Creates a booster pack for the specified game using Steam gems.
131    ///
132    /// # Arguments
133    ///
134    /// * `appid` - The App ID of the game to create a booster for.
135    /// * `use_untradable_gems` - If `true`, untradable gems will be used first.
136    #[steam_endpoint(POST, host = Community, path = "/tradingcards/ajaxcreatebooster/", kind = Write)]
137    pub async fn create_booster_pack(&self, appid: AppId, use_untradable_gems: bool) -> Result<BoosterResult, SteamUserError> {
138        let pref = if use_untradable_gems { "3" } else { "2" };
139        let appid_str = appid.to_string();
140        let response: serde_json::Value = self.post_path("/tradingcards/ajaxcreatebooster/").form(&[("appid", appid_str.as_str()), ("series", "1"), ("tradability_preference", pref)]).send().await?.json().await?;
141
142        Self::check_json_success(&response, "Failed to create booster pack")?;
143
144        Ok(BoosterResult {
145            total_gems: response.get("goo_amount").and_then(|v| v.as_str()).and_then(|s| s.parse().ok()).unwrap_or(0),
146            tradable_gems: response.get("tradable_goo_amount").and_then(|v| v.as_str()).and_then(|s| s.parse().ok()).unwrap_or(0),
147            untradable_gems: response.get("untradable_goo_amount").and_then(|v| v.as_str()).and_then(|s| s.parse().ok()).unwrap_or(0),
148            result_item: response.get("purchase_result").cloned().unwrap_or(serde_json::Value::Null),
149        })
150    }
151
152    /// Retrieves the booster pack catalog for the authenticated user.
153    ///
154    /// Returns a list of games for which the user is eligible to create booster
155    /// packs, including the gem cost and next available creation time.
156    #[steam_endpoint(GET, host = Community, path = "/tradingcards/boostersearch/", kind = Read)]
157    pub async fn get_booster_pack_catalog(&self) -> Result<Vec<BoosterPackEntry>, SteamUserError> {
158        let response = self.get_path("/tradingcards/boostersearch/").send().await?.text().await?;
159
160        let caps = get_booster_data_re().captures(&response).ok_or_else(|| SteamUserError::MalformedResponse("Could not find booster pack data in response".to_string()))?;
161
162        let json_str = caps.get(1).map(|m| m.as_str()).unwrap_or("[]");
163        let catalog: Vec<BoosterPackEntry> = serde_json::from_str(json_str)?;
164
165        Ok(catalog)
166    }
167
168    /// Retrieves the currently active market listings for the authenticated
169    /// user.
170    ///
171    /// # Arguments
172    ///
173    /// * `start` - The starting index (offset) for results.
174    /// * `count` - The number of results to fetch (max 100).
175    ///
176    /// # Returns
177    ///
178    /// Returns a tuple containing:
179    /// - `Vec<MarketListing>`: The list of active market listings.
180    /// - `Vec<serde_json::Value>`: Associated asset data for the listed items.
181    /// - `u32`: Total number of active listings on Steam (for pagination).
182    #[steam_endpoint(GET, host = Community, path = "/market/mylistings/render/", kind = Read)]
183    pub async fn get_my_listings(&self, start: u32, count: u32) -> Result<(Vec<MarketListing>, Vec<serde_json::Value>, u32), SteamUserError> {
184        let url = format!("https://steamcommunity.com/market/mylistings/render/?start={}&count={}", start, count);
185        let text = self.get_with_manual_redirects(&url).await?;
186        let response = serde_json::from_str::<serde_json::Value>(&text)?;
187
188        let success_val = response.get("success");
189        let is_success = success_val.and_then(|v| v.as_bool()).unwrap_or(false) || success_val.and_then(|v| v.as_i64()) == Some(1);
190        if !is_success {
191            return Err(SteamUserError::MalformedResponse("Failed to fetch market listings".to_string()));
192        }
193
194        let total_count = u32::try_from(response.get("total_count").and_then(|v| v.as_u64()).unwrap_or(0)).unwrap_or(u32::MAX);
195
196        let html = response.get("results_html").and_then(|v| v.as_str()).unwrap_or("");
197        let list = parse_market_listings(html)?;
198
199        let assets_val = response.get("assets").cloned().unwrap_or_default();
200        let mut assets = Vec::new();
201        if let Some(apps) = assets_val.as_object() {
202            for app_assets in apps.values() {
203                if let Some(contexts) = app_assets.as_object() {
204                    for context_assets in contexts.values() {
205                        if let Some(items) = context_assets.as_object() {
206                            for item in items.values() {
207                                assets.push(item.clone());
208                            }
209                        }
210                    }
211                }
212            }
213        }
214
215        Ok((list, assets, total_count))
216    }
217
218    /// Retrieves the market transaction history for the authenticated user.
219    ///
220    /// # Arguments
221    ///
222    /// * `start` - The starting index (offset) for results.
223    /// * `count` - The number of results to fetch (max 100).
224    #[steam_endpoint(GET, host = Community, path = "/market/myhistory/render/", kind = Read)]
225    pub async fn get_market_history(&self, start: u32, count: u32) -> Result<MarketHistoryResponse, SteamUserError> {
226        let url = format!("https://steamcommunity.com/market/myhistory/render/?query=&start={}&count={}", start, count);
227        let text = self.get_with_manual_redirects(&url).await?;
228        let response = serde_json::from_str::<serde_json::Value>(&text)?;
229
230        let success_val = response.get("success");
231        let is_success = success_val.and_then(|v| v.as_bool()).unwrap_or(false) || success_val.and_then(|v| v.as_i64()) == Some(1);
232        if !is_success {
233            return Err(SteamUserError::MalformedResponse("Failed to fetch market history".to_string()));
234        }
235
236        let hovers_html = response.get("hovers").and_then(|v| v.as_str()).unwrap_or("");
237        let asset_by_listing = extract_asset_items_from_hovers(hovers_html);
238
239        let html = response.get("results_html").and_then(|v| v.as_str()).unwrap_or("");
240        let list = parse_market_history_listings(html, &asset_by_listing)?;
241
242        let assets_val = response.get("assets").cloned().unwrap_or_default();
243        let mut assets = Vec::new();
244        if let Some(apps) = assets_val.as_object() {
245            for app_assets in apps.values() {
246                if let Some(contexts) = app_assets.as_object() {
247                    for context_assets in contexts.values() {
248                        if let Some(items) = context_assets.as_object() {
249                            for item in items.values() {
250                                assets.push(item.clone());
251                            }
252                        }
253                    }
254                }
255            }
256        }
257
258        let total_count = u32::try_from(response.get("total_count").and_then(|v| v.as_u64()).unwrap_or(0)).unwrap_or(u32::MAX);
259
260        Ok(MarketHistoryResponse { success: true, list, assets, total_count, start, count })
261    }
262
263    /// Lists an item for sale on the Steam Community Market.
264    ///
265    /// # Arguments
266    ///
267    /// * `price` - The price the SELLER receives, in cents (currency × 100).
268    ///   For VND: 10,000₫ seller-receive → `price = 1_000_000`.
269    #[steam_endpoint(POST, host = Community, path = "/market/sellitem", kind = Write)]
270    pub async fn sell_item(&self, appid: crate::types::AppId, contextid: crate::types::ContextId, assetid: crate::types::AssetId, amount: crate::types::Amount, price: crate::types::PriceCents) -> Result<SellItemResult, SteamUserError> {
271        let my_steam_id = self.session.get_steam_id().steam_id64().to_string();
272        let appid_str = appid.to_string();
273        let contextid_str = contextid.to_string();
274        let assetid_str = assetid.to_string();
275        let amount_str = amount.to_string();
276        let price_str = price.to_string();
277
278        let response: serde_json::Value = self.post_path("/market/sellitem").form(&[("appid", &appid_str), ("contextid", &contextid_str), ("assetid", &assetid_str), ("amount", &amount_str), ("price", &price_str)]).header("Referer", format!("https://steamcommunity.com/profiles/{}/inventory/", my_steam_id)).send().await?.json().await?;
279
280        let success = response.get("success").and_then(|v| v.as_bool()).unwrap_or(false);
281        let error = response.get("message").and_then(|v| v.as_str()).map(|s| s.to_string());
282
283        Ok(SellItemResult {
284            success,
285            requires_confirmation: response.get("requires_confirmation").and_then(|v| v.as_bool()),
286            needs_mobile_confirmation: response.get("needs_mobile_confirmation").and_then(|v| v.as_bool()),
287            needs_email_confirmation: response.get("needs_email_confirmation").and_then(|v| v.as_bool()),
288            email_domain: response.get("email_domain").and_then(|v| v.as_str()).map(|s| s.to_string()),
289            error,
290        })
291    }
292
293    /// Retrieves a list of all applications that have items listed on the Steam
294    /// Community Market.
295    ///
296    /// # Returns
297    ///
298    /// Returns a `HashMap<u32, String>` where the key is the App ID and the
299    /// value is the application name.
300    #[steam_endpoint(GET, host = Community, path = "/market/", kind = Read)]
301    pub async fn get_market_apps(&self) -> Result<HashMap<u32, String>, SteamUserError> {
302        let response = self.get_with_manual_redirects("https://steamcommunity.com/market/").await?;
303
304        // Parse HTML on the blocking pool so the async runtime stays
305        // responsive when Steam returns a large market index page.
306        tokio::task::spawn_blocking(move || parse_market_apps(&response)).await.map_err(|e| SteamUserError::Other(format!("market-apps parse task failed: {e}")))?
307    }
308
309    /// Cancels and removes an active listing from the Steam Community Market.
310    #[steam_endpoint(POST, host = Community, path = "/market/removelisting/{listing_id}", kind = Write)]
311    pub async fn remove_listing(&self, listing_id: &str) -> Result<bool, SteamUserError> {
312        let response = self.post_path(format!("/market/removelisting/{}", listing_id)).form(&([] as [(&str, &str); 0])).header("Referer", "https://steamcommunity.com/market/").send().await?;
313
314        Ok(response.status().is_success())
315    }
316
317    /// Checks for any active Steam Market restrictions or warnings on the
318    /// authenticated account.
319    #[steam_endpoint(GET, host = Community, path = "/market/", kind = Read)]
320    pub async fn get_market_restrictions(&self) -> Result<(MarketRestrictions, Option<WalletBalance>), SteamUserError> {
321        let response = self.get_with_manual_redirects("https://steamcommunity.com/market/").await?;
322
323        let document = Html::parse_document(&response);
324
325        let wallet = {
326            let w = parse_wallet_balance(&document);
327            if w.main_balance.is_some() {
328                Some(w)
329            } else {
330                None
331            }
332        };
333
334        // Check for login requirement
335        let login_selector = Selector::parse(".market_login_link_ctn").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
336        if document.select(&login_selector).next().is_some() {
337            return Ok((
338                MarketRestrictions {
339                    success: false,
340                    warning: Some("User not logged in".to_string()),
341                    restrictions: Vec::new(),
342                    restriction_expire: None,
343                    time_can_use: None,
344                },
345                wallet,
346            ));
347        }
348
349        let warning_selector = Selector::parse(".market_headertip_container_warning").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
350        let warning_box = document.select(&warning_selector).next();
351
352        if let Some(box_elem) = warning_box {
353            let header_selector = Selector::parse("#market_warning_header").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
354            let warning = box_elem.select(&header_selector).next().map(|e| clean_space(&e.text().collect::<String>()));
355
356            let li_selector = Selector::parse("ul.market_restrictions > li").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
357            let restrictions = box_elem.select(&li_selector).map(|li| clean_space(&li.text().collect::<String>())).collect();
358
359            let expire_selector = Selector::parse("#market_restriction_expire").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
360            let restriction_expire = box_elem.select(&expire_selector).next().map(|e| clean_space(&e.text().collect::<String>()));
361
362            let time_header_selector = Selector::parse("#market_timecanuse_header").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
363            let _time_can_use_text = box_elem.select(&time_header_selector).next().map(|e| clean_space(&e.text().collect::<String>()));
364
365            let time_can_use = _time_can_use_text.and_then(|text| chrono::DateTime::parse_from_str(&text, "%a, %d %b %Y %H:%M:%S %z").map(|dt| dt.timestamp_millis()).ok());
366
367            Ok((MarketRestrictions { success: true, warning, restrictions, restriction_expire, time_can_use }, wallet))
368        } else {
369            Ok((MarketRestrictions { success: true, warning: None, restrictions: Vec::new(), restriction_expire: None, time_can_use: None }, wallet))
370        }
371    }
372
373    /// Retrieves the internal `item_nameid` for a Steam Community Market item.
374    ///
375    /// This ID is required for the orders histogram API and is extracted from
376    /// the market listing page's JavaScript code.
377    ///
378    /// # Arguments
379    ///
380    /// * `app_id` - The App ID of the game (e.g., 730 for CS2).
381    /// * `market_hash_name` - The market hash name of the item.
382    ///
383    /// # Returns
384    ///
385    /// Returns the numeric `item_nameid` used by Steam for order book queries.
386    ///
387    /// # Example
388    ///
389    /// ```no_run
390    /// # use steam_user::SteamUser;
391    /// # use steam_user::types::AppId;
392    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
393    /// let client = SteamUser::new(&["cookies"])?;
394    /// let item_nameid = client
395    ///     .get_item_nameid(AppId::new(730), "AK-47 | Redline (Field-Tested)")
396    ///     .await?;
397    /// println!("Item nameid: {}", item_nameid);
398    /// # Ok(())
399    /// # }
400    /// ```
401    #[steam_endpoint(GET, host = Community, path = "/market/listings/{app_id}/{market_hash_name}", kind = Read)]
402    pub async fn get_item_nameid(&self, app_id: AppId, market_hash_name: &str) -> Result<ItemNameId, SteamUserError> {
403        let url = format!("https://steamcommunity.com/market/listings/{}/{}", app_id, urlencoding::encode(market_hash_name));
404
405        let response = self.get_with_manual_redirects(&url).await?;
406
407        let item_nameid = get_item_nameid_re().captures(&response).and_then(|c| c.get(1)).and_then(|m| m.as_str().parse::<u64>().ok()).ok_or_else(|| SteamUserError::MalformedResponse("Could not find item_nameid in market listing page".into()))?;
408
409        Ok(ItemNameId::new(item_nameid))
410    }
411
412    /// Retrieves the buy/sell order histogram for a Steam Community Market
413    /// item.
414    ///
415    /// This function fetches the order book data showing buy and sell orders at
416    /// different price points for a specific market item.
417    ///
418    /// # Arguments
419    ///
420    /// * `item_nameid` - The internal item ID (obtained from
421    ///   [`Self::get_item_nameid`]).
422    /// * `country` - Country code (e.g., "VN", "US", "DE").
423    /// * `currency` - Steam currency code:
424    ///   - 1 = USD
425    ///   - 2 = GBP
426    ///   - 3 = EUR
427    ///   - 15 = VND
428    ///   - See Steam documentation for full list.
429    ///
430    /// # Returns
431    ///
432    /// Returns an [`ItemOrdersHistogramResponse`] containing:
433    /// - Highest buy order and lowest sell order prices
434    /// - Buy and sell order graph data
435    /// - Price formatting information
436    ///
437    /// # Example
438    ///
439    /// ```no_run
440    /// # use steam_user::SteamUser;
441    /// # use steam_user::types::AppId;
442    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
443    /// let client = SteamUser::new(&["cookies"])?;
444    ///
445    /// // First get the item_nameid
446    /// let item_nameid = client
447    ///     .get_item_nameid(AppId::new(730), "AK-47 | Redline (Field-Tested)")
448    ///     .await?;
449    ///
450    /// // Then fetch the order histogram
451    /// let histogram = client
452    ///     .get_item_orders_histogram(item_nameid, "US", 1)
453    ///     .await?;
454    ///
455    /// if let Some(highest_buy) = &histogram.highest_buy_order {
456    ///     println!("Highest buy order: {}", highest_buy);
457    /// }
458    /// if let Some(lowest_sell) = &histogram.lowest_sell_order {
459    ///     println!("Lowest sell order: {}", lowest_sell);
460    /// }
461    /// # Ok(())
462    /// # }
463    /// ```
464    #[steam_endpoint(GET, host = Community, path = "/market/itemordershistogram", kind = Read)]
465    pub async fn get_item_orders_histogram(&self, item_nameid: ItemNameId, country: &str, currency: u32) -> Result<ItemOrdersHistogramResponse, SteamUserError> {
466        let url = format!("https://steamcommunity.com/market/itemordershistogram?country={}&language=english&currency={}&item_nameid={}&two_factor=0", country, currency, item_nameid);
467        let text = self.get_with_manual_redirects(&url).await?;
468        let response = serde_json::from_str::<ItemOrdersHistogramResponse>(&text)?;
469
470        if response.success != 1 {
471            return Err(SteamUserError::MalformedResponse("Failed to fetch item orders histogram".to_string()));
472        }
473
474        Ok(response)
475    }
476}
477
478/// Parses the Steam Market index HTML and returns a map of `appid → app name`.
479///
480/// Extracted as a pure synchronous function so callers can dispatch it onto
481/// `tokio::task::spawn_blocking` and keep the async runtime responsive.
482fn parse_market_apps(html: &str) -> Result<HashMap<u32, String>, SteamUserError> {
483    let document = Html::parse_document(html);
484    let selector = Selector::parse(".market_search_game_button_group a.game_button").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
485    let name_selector = Selector::parse(".game_button_game_name").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
486
487    let mut apps = HashMap::new();
488
489    for element in document.select(&selector) {
490        if let Some(href) = element.value().attr("href") {
491            if let Some(idx) = href.find('=') {
492                if let Ok(appid) = href[idx + 1..].parse::<u32>() {
493                    let name = element.select(&name_selector).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
494
495                    apps.insert(appid, name);
496                }
497            }
498        }
499    }
500
501    Ok(apps)
502}
503
504fn parse_market_listings(html: &str) -> Result<Vec<MarketListing>, SteamUserError> {
505    let document = Html::parse_fragment(html);
506    let row_selector = Selector::parse(".market_listing_row").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
507    let mut list = Vec::new();
508
509    for row in document.select(&row_selector) {
510        let cancel_selector = Selector::parse(".market_listing_cancel_button > a").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
511        if let Some(cancel_link) = row.select(&cancel_selector).next().and_then(|a| a.value().attr("href")) {
512            if let Some(start) = cancel_link.find('(') {
513                if let Some(end) = cancel_link.find(')') {
514                    let params: Vec<&str> = cancel_link[start + 1..end].split(',').map(|s| s.trim().trim_matches('\'').trim_matches('"')).collect();
515
516                    if params.len() >= 4 {
517                        // Steam usually passes "mylisting" as the first argument:
518                        // RemoveMarketListing('mylisting', 'listing_id', appid, contextid, 'item_id')
519                        let offset = if params[0] == "mylisting" { 1 } else { 0 };
520                        if params.len() >= 4 + offset {
521                            let listing_id = params[offset].to_string();
522                            let appid = params[1 + offset].parse().unwrap_or(0);
523                            let contextid = params[2 + offset].parse().unwrap_or(0);
524                            let item_id = params[3 + offset].parse().unwrap_or(0);
525
526                            let img_selector = Selector::parse(&format!("#mylisting_{}_image", listing_id)).map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
527                            let img_el = row.select(&img_selector).next();
528                            let image_url = img_el.and_then(|img| img.value().attr("src")).map(|s| s.to_string());
529                            let img_style = img_el.and_then(|img| img.value().attr("style")).unwrap_or("");
530                            let name_color = parse_style_hex_color(img_style, "border-color");
531                            let background_color = parse_style_hex_color(img_style, "background-color");
532
533                            let buyer_pays_selector = Selector::parse(".market_listing_price span[title=\"This is the price the buyer pays.\"]").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
534                            let buyer_pays_price = row.select(&buyer_pays_selector).next().map(|e| clean_space(&e.text().collect::<String>().replace(['(', ')'], ""))).unwrap_or_default();
535
536                            let receive_selector = Selector::parse(".market_listing_price span[title=\"This is how much you will receive.\"]").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
537                            let receive_price = row.select(&receive_selector).next().map(|e| clean_space(&e.text().collect::<String>().replace(['(', ')'], ""))).unwrap_or_default();
538
539                            let name_selector = Selector::parse(".market_listing_item_name_link").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
540                            let item_name = row.select(&name_selector).next().map(|e| clean_space(&e.text().collect::<String>())).unwrap_or_else(|| {
541                                // Fallback
542                                // Ideally we would want to propagate errors here too, but since we are inside
543                                // closure/fallback, keeping it simple or pre-calculating
544                                String::new()
545                            });
546
547                            // Re-do item name gracefully if first selector failed to match (which is what
548                            // unwrap_or_else handled) But wait, the previous code
549                            // had a nested selector parse inside unwrap_or_else. We
550                            // should extract that.
551
552                            let final_item_name = if !item_name.is_empty() {
553                                item_name
554                            } else {
555                                let alt_name_selector = Selector::parse(".market_listing_item_name").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
556                                row.select(&alt_name_selector).next().map(|e| clean_space(&e.text().collect::<String>())).unwrap_or_default()
557                            };
558
559                            let game_selector = Selector::parse(".market_listing_game_name").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
560                            let game_name = row.select(&game_selector).next().map(|e| clean_space(&e.text().collect::<String>())).unwrap_or_default();
561
562                            let listed_date_selector = Selector::parse(".market_listing_listed_date_combined").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
563                            let listed_date = row.select(&listed_date_selector).next().map(|e| clean_space(&e.text().collect::<String>())).unwrap_or_default();
564
565                            list.push(MarketListing {
566                                listing_id,
567                                appid,
568                                contextid,
569                                item_id,
570                                image_url,
571                                buyer_pays_price,
572                                receive_price,
573                                item_name: final_item_name,
574                                game_name,
575                                listed_date,
576                                name_color,
577                                background_color,
578                            });
579                        }
580                    }
581                }
582            }
583        }
584    }
585    Ok(list)
586}
587
588fn parse_market_history_listings(html: &str, asset_by_listing: &HashMap<String, u64>) -> Result<Vec<MarketHistoryListing>, SteamUserError> {
589    let document = Html::parse_fragment(html);
590    let row_selector = Selector::parse(".market_listing_row").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
591    let mut list = Vec::new();
592
593    for row in document.select(&row_selector) {
594        let id = row.value().attr("id").unwrap_or_default().to_string();
595        let listing_id = get_listing_id_re().captures(&id).and_then(|c| c.get(1)).map(|m| m.as_str().to_string()).unwrap_or_default();
596
597        if listing_id.is_empty() {
598            continue;
599        }
600
601        let gain_loss_selector = Selector::parse(".market_listing_gainorloss").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
602        let gain_or_loss = row.select(&gain_loss_selector).next().map(|e| clean_space(&e.text().collect::<String>())).unwrap_or_default();
603
604        let img_selector = Selector::parse(".market_listing_item_img").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
605        let img_el = row.select(&img_selector).next();
606        let image = img_el.and_then(|img| img.value().attr("src")).map(|s| s.to_string());
607        let img_style = img_el.and_then(|img| img.value().attr("style")).unwrap_or("");
608        let name_color = parse_style_hex_color(img_style, "border-color");
609        let background_color = parse_style_hex_color(img_style, "background-color");
610
611        let price_selector = Selector::parse(".market_table_value .market_listing_price").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
612        let price = row.select(&price_selector).next().map(|e| clean_space(&e.text().collect::<String>())).unwrap_or_default();
613
614        let name_selector = Selector::parse(".market_listing_item_name").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
615        let item_name = row.select(&name_selector).next().map(|e| clean_space(&e.text().collect::<String>())).unwrap_or_default();
616
617        let game_selector = Selector::parse(".market_listing_game_name").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
618        let game_name = row.select(&game_selector).next().map(|e| clean_space(&e.text().collect::<String>())).unwrap_or_default();
619
620        // Dates: first .can_combine = acted_on, second = listed_on (matches Steam's
621        // column order)
622        let date_selector = Selector::parse(".market_listing_listed_date.can_combine").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
623        let dates: Vec<_> = row.select(&date_selector).collect();
624        let acted_on = dates.first().map(|e| clean_space(&e.text().collect::<String>())).unwrap_or_default();
625        let listed_on = dates.get(1).map(|e| clean_space(&e.text().collect::<String>())).unwrap_or_default();
626
627        // Status from combined label: "Sold: 5 Apr", "Purchased: 8 Jun"
628        let combined_selector = Selector::parse(".market_listing_listed_date_combined").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
629        let status = row.select(&combined_selector).next().map(|e| clean_space(&e.text().collect::<String>())).unwrap_or_default();
630
631        let asset_id = asset_by_listing.get(&listing_id).cloned();
632
633        list.push(MarketHistoryListing {
634            id,
635            listing_id,
636            price,
637            item_name,
638            game_name,
639            listed_on,
640            acted_on,
641            image,
642            gain_or_loss,
643            status,
644            asset_id,
645            name_color,
646            background_color,
647        });
648    }
649    Ok(list)
650}
651
652fn extract_asset_items_from_hovers(html: &str) -> HashMap<String, u64> {
653    let mut map = HashMap::new();
654
655    for cap in get_hover_re().captures_iter(html) {
656        let selector = cap.get(1).map_or("", |m| m.as_str());
657        let assetid = cap.get(4).map_or(0, |m| m.as_str().parse::<u64>().unwrap_or(0));
658
659        if let Some(lid_match) = get_listing_row_re().captures(selector) {
660            if let Some(listing_id) = lid_match.get(1) {
661                map.insert(listing_id.as_str().to_string(), assetid);
662            }
663        }
664    }
665    map
666}