Skip to main content

steam_user/services/
match_history.rs

1//! Match history service for CS:GO/CS2 match data retrieval.
2
3use std::{collections::HashMap, sync::OnceLock};
4
5use chrono::{DateTime, Duration, NaiveDateTime, Utc};
6use scraper::{Html, Selector};
7use sha2::{Digest, Sha256};
8
9use crate::{
10    client::SteamUser,
11    endpoint::steam_endpoint,
12    error::SteamUserError,
13    types::match_history::{Match, MatchHistoryResponse, MatchHistoryType, MatchPlayer, Team},
14    utils::avatar::get_avatar_hash_from_url,
15};
16
17/// CS:GO/CS2 App ID.
18const CSGO_APP_ID: u32 = 730;
19
20static SEL_SCOREBOARD_TABLE: OnceLock<Selector> = OnceLock::new();
21fn sel_scoreboard_table() -> &'static Selector {
22    SEL_SCOREBOARD_TABLE.get_or_init(|| Selector::parse("table.csgo_scoreboard_inner_right").expect("valid CSS selector"))
23}
24
25static SEL_TR: OnceLock<Selector> = OnceLock::new();
26fn sel_tr() -> &'static Selector {
27    SEL_TR.get_or_init(|| Selector::parse("tr").expect("valid CSS selector"))
28}
29
30static SEL_TH: OnceLock<Selector> = OnceLock::new();
31fn sel_th() -> &'static Selector {
32    SEL_TH.get_or_init(|| Selector::parse("th").expect("valid CSS selector"))
33}
34
35static SEL_TD: OnceLock<Selector> = OnceLock::new();
36fn sel_td() -> &'static Selector {
37    SEL_TD.get_or_init(|| Selector::parse("td").expect("valid CSS selector"))
38}
39
40static SEL_LEFT_TABLE_TD: OnceLock<Selector> = OnceLock::new();
41fn sel_left_table_td() -> &'static Selector {
42    SEL_LEFT_TABLE_TD.get_or_init(|| Selector::parse("table.csgo_scoreboard_inner_left tr > td").expect("valid CSS selector"))
43}
44
45static SEL_ANCHOR: OnceLock<Selector> = OnceLock::new();
46fn sel_anchor() -> &'static Selector {
47    SEL_ANCHOR.get_or_init(|| Selector::parse("a").expect("valid CSS selector"))
48}
49
50static SEL_LINK_TITLE: OnceLock<Selector> = OnceLock::new();
51fn sel_link_title() -> &'static Selector {
52    SEL_LINK_TITLE.get_or_init(|| Selector::parse("a.linkTitle").expect("valid CSS selector"))
53}
54
55static SEL_PLAYER_AVATAR_IMG: OnceLock<Selector> = OnceLock::new();
56fn sel_player_avatar_img() -> &'static Selector {
57    SEL_PLAYER_AVATAR_IMG.get_or_init(|| Selector::parse(".playerAvatar a > img[src]").expect("valid CSS selector"))
58}
59
60impl SteamUser {
61    /// Retrieves the CS:GO/CS2 match history for a specific mode and tab.
62    ///
63    /// # Arguments
64    ///
65    /// * `match_type` - The match history type to fetch.
66    /// * `token` - Optional continuation token for pagination.
67    ///
68    /// # Returns
69    ///
70    /// Returns a [`MatchHistoryResponse`] containing the parsed matches and the
71    /// next continuation token.
72    #[steam_endpoint(GET, host = Community, path = "/profiles/{steam_id}/gcpd/730/", kind = Read)]
73    pub async fn get_match_history(&self, match_type: MatchHistoryType, token: Option<&str>) -> Result<MatchHistoryResponse, SteamUserError> {
74        match token {
75            Some(t) if !t.is_empty() => self.get_paginated_match_history(match_type, t).await,
76            _ => self.get_initial_match_history(match_type).await,
77        }
78    }
79
80    /// Fetches the initial match history page (HTML response).
81    #[steam_endpoint(GET, host = Community, path = "/profiles/{steam_id}/gcpd/{app_id}/", kind = Read)]
82    #[tracing::instrument(skip(self))]
83    async fn get_initial_match_history(&self, match_type: MatchHistoryType) -> Result<MatchHistoryResponse, SteamUserError> {
84        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
85
86        // Initial request does not use ajax=1 according to user requirement
87        let response = self.get_path(format!("/profiles/{}/gcpd/{}/", steam_id.steam_id64(), CSGO_APP_ID)).query(&[("tab", match_type.as_str())]).send().await?;
88        self.check_response(&response)?;
89
90        let text = response.text().await?;
91
92        // Extract continue token + text first (cheap string finds), then hand
93        // the body off to a blocking task for the heavy HTML parse + selector
94        // traversal so the async runtime stays responsive.
95        let continue_token = extract_between(&text, "var g_sGcContinueToken =", ";").map(|s| s.trim().trim_matches('\'').to_string()).unwrap_or_default();
96        let continue_text = extract_between(&text, "load_more_button_continue_text\" class=\"returnLink\">", "</div>").map(|s| s.to_string()).unwrap_or_default();
97
98        let matches = tokio::task::spawn_blocking(move || parse_match_history(&text, match_type)).await.map_err(|e| SteamUserError::Other(format!("match-history parse task failed: {e}")))?;
99
100        Ok(MatchHistoryResponse { continue_token, continue_text, matches })
101    }
102
103    /// Fetches subsequent match history pages using AJAX (JSON response).
104    #[steam_endpoint(GET, host = Community, path = "/profiles/{steam_id}/gcpd/{app_id}", kind = Read)]
105    #[tracing::instrument(skip(self))]
106    async fn get_paginated_match_history(&self, match_type: MatchHistoryType, token: &str) -> Result<MatchHistoryResponse, SteamUserError> {
107        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
108
109        // AJAX request uses ajax=1 and continue_token.
110        // sessionid is automatically added by self.get()
111        let params = vec![("ajax", "1"), ("tab", match_type.as_str()), ("continue_token", token)];
112
113        let response = self.get_path(format!("/profiles/{}/gcpd/{}", steam_id.steam_id64(), CSGO_APP_ID)).query(&params).send().await?;
114        self.check_response(&response)?;
115
116        let json = response.json::<serde_json::Value>().await?;
117
118        let continue_token = json.get("continue_token").and_then(|v| v.as_str()).unwrap_or("").to_string();
119        let continue_text = json.get("continue_text").and_then(|v| v.as_str()).unwrap_or("").to_string();
120        let html = json.get("html").and_then(|v| v.as_str()).unwrap_or("").to_string();
121
122        // Heavy HTML parse runs on the blocking pool so the async runtime
123        // stays responsive on large match-history responses.
124        let matches = tokio::task::spawn_blocking(move || parse_match_history(&html, match_type)).await.map_err(|e| SteamUserError::Other(format!("match-history parse task failed: {e}")))?;
125
126        Ok(MatchHistoryResponse { continue_token, continue_text, matches })
127    }
128}
129
130/// Helper to extract a substring between two delimiters.
131fn extract_between<'a>(text: &'a str, start: &str, end: &str) -> Option<&'a str> {
132    let start_idx = text.find(start)? + start.len();
133    let rest = &text[start_idx..];
134    let end_idx = rest.find(end)?;
135    Some(&rest[..end_idx])
136}
137
138/// Parses match history HTML and returns a list of Match structs.
139fn parse_match_history(html: &str, match_type: MatchHistoryType) -> Vec<Match> {
140    if html.is_empty() {
141        return Vec::new();
142    }
143
144    let document = Html::parse_document(html);
145    let mut matches = Vec::new();
146
147    // Select each match scoreboard table
148    for table in document.select(sel_scoreboard_table()) {
149        if let Some(m) = parse_single_match(&document, &table, match_type) {
150            matches.push(m);
151        }
152    }
153
154    matches
155}
156
157/// Parses a single match from its scoreboard table.
158#[tracing::instrument(skip(_document, table))]
159fn parse_single_match(_document: &Html, table: &scraper::ElementRef, match_type: MatchHistoryType) -> Option<Match> {
160    // Local variables to collect match info (previously in MatchInfo struct)
161    let mut map: Option<String> = None;
162    let mut time: Option<DateTime<Utc>> = None;
163    let mut timestamp: Option<i64> = None;
164    let mut wait_time: Option<Duration> = None;
165    let mut duration: Option<Duration> = None;
166    let mut gotv_replay: Option<String> = None;
167    let mut viewers: Option<i32> = None;
168    let mut ranked = false;
169
170    // Find parent tr element to get match info
171    // We need to traverse up to find the match info table on the left side
172    // This is a bit tricky with scraper, so we'll parse from the document
173
174    // Get the table's HTML to find context
175    let _table_html = table.html();
176
177    // Try to find corresponding match info by looking at sibling elements
178    // For now, we'll parse the player table and extract what we can
179
180    let rows: Vec<_> = table.select(sel_tr()).collect();
181
182    if rows.is_empty() {
183        return None;
184    }
185
186    // First row should be headers
187    let headers: Vec<String> = rows.first()?.select(sel_th()).map(|h| h.text().collect::<String>().trim().to_string()).collect();
188
189    if headers.is_empty() {
190        return None;
191    }
192
193    // Parse player rows
194    let mut history_table: Vec<HashMap<String, String>> = Vec::new();
195    let mut player_name_data: Vec<PlayerNameData> = Vec::new();
196
197    for (i, row) in rows.iter().enumerate() {
198        if i == 0 {
199            continue; // Skip header row
200        }
201
202        let cells: Vec<_> = row.select(sel_td()).collect();
203        let mut row_data = HashMap::new();
204
205        for (j, cell) in cells.iter().enumerate() {
206            if j >= headers.len() {
207                continue;
208            }
209            let header = &headers[j];
210
211            // Check if this is the player name column
212            if cell.value().attr("class").unwrap_or("").contains("inner_name") {
213                let name_data = parse_player_name_cell(cell);
214                player_name_data.push(name_data.clone());
215                row_data.insert(header.clone(), name_data.name);
216            } else {
217                row_data.insert(header.clone(), cell.text().collect::<String>().trim().to_string());
218            }
219        }
220
221        history_table.push(row_data);
222    }
223
224    if history_table.is_empty() {
225        return None;
226    }
227
228    // Find scoreboard row (middle row with score like "16 : 14")
229    let scoreboard_index = history_table.len() / 2;
230    if scoreboard_index < 1 || scoreboard_index >= history_table.len() {
231        return None;
232    }
233
234    // Parse score from the scoreboard row
235    let score_str = history_table.get(scoreboard_index)?.get("Player Name").cloned().unwrap_or_default();
236
237    let scores: Vec<i32> = score_str.split(':').map(|s| s.trim().parse().unwrap_or(0)).collect();
238
239    let ct_score = scores.first().copied().unwrap_or(0);
240    let t_score = scores.get(1).copied().unwrap_or(0);
241
242    // Parse players
243    let mut players = Vec::new();
244    let mut name_data_iter = player_name_data.iter();
245
246    for (i, row) in history_table.iter().enumerate() {
247        // Log HTML of the corresponding row (i+1 because rows[0] is the header)
248        if let Some(html_row) = rows.get(i + 1) {
249            tracing::trace!(row = i, html = %html_row.html(), "match history row");
250        }
251
252        if i == scoreboard_index {
253            // Skip the scoreboard row - it doesn't have inner_name class,
254            // so no entry was added to player_name_data for it
255            continue;
256        }
257
258        let team = if i < scoreboard_index { Team::Ct } else { Team::T };
259        let name_data = name_data_iter.next();
260
261        let mut player = MatchPlayer { team, ..Default::default() };
262
263        if let Some(nd) = name_data {
264            player.name = nd.name.clone();
265            player.link = nd.link.clone();
266            player.miniprofile = nd.miniprofile;
267            player.avatar_hash = nd.avatar_hash.clone();
268            player.custom_url = nd.custom_url.clone();
269
270            // Convert miniprofile (account ID) to steam ID 64
271            if let Some(mp) = nd.miniprofile {
272                player.steam_id = Some(steamid::SteamID::from_individual_account_id(mp));
273            }
274        }
275
276        // Parse numeric stats
277        player.ping = row.get("Ping").and_then(|s| s.parse().ok());
278        player.kills = row.get("K").and_then(|s| s.parse().ok());
279        player.assists = row.get("A").and_then(|s| s.parse().ok());
280        player.deaths = row.get("D").and_then(|s| s.parse().ok());
281        player.score = row.get("Score").and_then(|s| s.parse().ok());
282
283        // Parse MVP stars
284        if let Some(mvp_str) = row.get("★") {
285            player.mvp = parse_mvp(mvp_str);
286        }
287
288        // Parse HSP (headshot percentage)
289        if let Some(hsp_str) = row.get("HSP") {
290            player.hsp = hsp_str.trim().trim_end_matches('%').parse().ok();
291        }
292
293        // Validate player validity based on miniprofile (Steam Account ID) presence
294        // This is much more reliable than checking for empty names or stats
295        let has_valid_id = if let Some(nd) = &name_data { nd.miniprofile.is_some() } else { false };
296
297        if !has_valid_id {
298            // Only log strict warning if it looks like a ghost row that might be confusing
299            if !player.name.is_empty() {
300                tracing::warn!(player_name = %player.name, "skipping player row: no miniprofile (likely invalid/bot)");
301            }
302            continue;
303        }
304
305        players.push(player);
306    }
307
308    // Try to parse match info from the left-side table
309    // This requires finding the parent structure
310    // Try to parse match info from the left-side table
311    // Traverse up to find the common parent (tr) that holds both left and right
312    // tables potential structure: tr -> td -> table.csgo_scoreboard_inner_right
313    // (current 'table') we want: tr -> td -> table.csgo_scoreboard_inner_left
314
315    // Note: table.parent() returns NodeRef, so we need to wrap it back into
316    // ElementRef to use select
317    let container_row_node = table.parent().and_then(|td| td.parent());
318
319    if let Some(row_node) = container_row_node {
320        if let Some(row) = scraper::ElementRef::wrap(row_node) {
321            // Scope selection to this specific row
322            let left_tds = row.select(sel_left_table_td());
323
324            for td in left_tds {
325                let text = td.text().collect::<String>().trim().to_string();
326
327                if text.starts_with("Competitive ") {
328                    map = Some(text.strip_prefix("Competitive ").unwrap_or(&text).to_string());
329                } else if text.starts_with("Premier ") {
330                    map = Some(text.strip_prefix("Premier ").unwrap_or(&text).to_string());
331                } else if text.ends_with(" GMT") {
332                    // Parse timestamp
333                    if let Some(dt) = parse_match_timestamp(&text) {
334                        time = Some(dt);
335                        timestamp = Some(dt.timestamp_millis());
336                    }
337                } else if text.starts_with("Wait Time: ") {
338                    wait_time = text.strip_prefix("Wait Time: ").and_then(|s| parse_duration(s.trim()));
339                } else if text.starts_with("Match Duration: ") {
340                    duration = text.strip_prefix("Match Duration: ").and_then(|s| parse_duration(s.trim()));
341                } else if text == "Download GOTV Replay" || text == "Download Replay" || text == "Tải bản phát lại" {
342                    // Find the link
343                    if let Some(a) = td.select(sel_anchor()).next() {
344                        gotv_replay = a.value().attr("href").map(|s| s.to_string());
345                    }
346                } else if text.starts_with("Viewers: ") {
347                    viewers = text.strip_prefix("Viewers: ").and_then(|s| s.parse().ok());
348                } else if text.starts_with("Ranked: Yes") {
349                    ranked = true;
350                }
351            }
352        }
353    }
354
355    // Generate match hash
356    let match_hash = generate_match_hash(map.as_deref(), time, duration, &players);
357
358    Some(Match {
359        match_hash,
360        map,
361        time,
362        timestamp,
363        wait_time,
364        duration,
365        gotv_replay,
366        viewers,
367        ranked,
368        players,
369        scoreboard: [ct_score, t_score],
370        match_type,
371    })
372}
373
374/// Player name data extracted from the name cell.
375#[derive(Debug, Clone, Default)]
376struct PlayerNameData {
377    name: String,
378    link: Option<String>,
379    miniprofile: Option<u32>,
380    avatar_hash: Option<String>,
381    custom_url: Option<String>,
382}
383
384/// Parses the player name cell to extract name, link, miniprofile, and avatar.
385fn parse_player_name_cell(cell: &scraper::ElementRef) -> PlayerNameData {
386    let mut data = PlayerNameData::default();
387
388    // Find the link - this is where the actual player name is stored
389    if let Some(a) = cell.select(sel_link_title()).next() {
390        // Get the name directly from the anchor element (most reliable)
391        data.name = a.text().collect::<String>().trim().to_string();
392        data.link = a.value().attr("href").map(|s| s.to_string());
393        data.miniprofile = a.value().attr("data-miniprofile").and_then(|s| s.parse().ok());
394
395        if data.miniprofile.is_none() {
396            tracing::warn!(player_name = %data.name, "failed to parse miniprofile for player");
397            if let Some(mp_str) = a.value().attr("data-miniprofile") {
398                tracing::warn!(data_miniprofile = %mp_str, "data-miniprofile attribute present but unparseable");
399            } else {
400                tracing::warn!("data-miniprofile attribute missing");
401            }
402        }
403
404        // Extract custom URL from link
405        if let Some(ref link) = data.link {
406            data.custom_url = get_custom_url_from_profile_url(link);
407        }
408    } else {
409        tracing::warn!(player_name = %data.name, "no profile link found for player");
410    }
411
412    // Find avatar
413    if let Some(img) = cell.select(sel_player_avatar_img()).next() {
414        if let Some(src) = img.value().attr("src") {
415            data.avatar_hash = get_avatar_hash_from_url(src);
416        }
417    }
418
419    data
420}
421
422// get_avatar_hash_from_url is imported from crate::utils::avatar
423
424/// Extracts custom URL from profile URL.
425fn get_custom_url_from_profile_url(url: &str) -> Option<String> {
426    if url.contains("/id/") {
427        url.split("/id/").nth(1).map(|s| s.trim_end_matches('/').to_string())
428    } else {
429        None
430    }
431}
432
433/// Parses match timestamp string to DateTime<Utc>.
434fn parse_match_timestamp(time_str: &str) -> Option<DateTime<Utc>> {
435    // Format: "2024-01-01 12:00:00 GMT"
436    let clean = time_str.replace(" GMT", "").trim().to_string();
437    NaiveDateTime::parse_from_str(&clean, "%Y-%m-%d %H:%M:%S").ok().map(|ndt| DateTime::from_naive_utc_and_offset(ndt, Utc))
438}
439
440/// Parses a duration string like "MM:SS" or "HH:MM:SS" into chrono::Duration.
441fn parse_duration(s: &str) -> Option<Duration> {
442    let parts: Vec<&str> = s.split(':').collect();
443    match parts.len() {
444        2 => {
445            // MM:SS
446            let mins: i64 = parts[0].parse().ok()?;
447            let secs: i64 = parts[1].parse().ok()?;
448            Some(Duration::minutes(mins) + Duration::seconds(secs))
449        }
450        3 => {
451            // HH:MM:SS
452            let hours: i64 = parts[0].parse().ok()?;
453            let mins: i64 = parts[1].parse().ok()?;
454            let secs: i64 = parts[2].parse().ok()?;
455            Some(Duration::hours(hours) + Duration::minutes(mins) + Duration::seconds(secs))
456        }
457        _ => None,
458    }
459}
460
461/// Parses MVP string to count.
462fn parse_mvp(mvp_str: &str) -> Option<i32> {
463    let trimmed = mvp_str.trim();
464    if trimmed.is_empty() {
465        Some(0)
466    } else if trimmed == "★" {
467        Some(1)
468    } else if trimmed.starts_with("★") {
469        trimmed.replace("★", "").trim().parse().ok()
470    } else {
471        Some(0)
472    }
473}
474
475/// Generates a unique match hash from match info.
476///
477/// Player miniprofiles (sorted) are included so that matches with partial
478/// parse failures (all-None metadata) still produce distinct hashes instead
479/// of all colliding on `sha256("")`.
480fn generate_match_hash(map: Option<&str>, time: Option<DateTime<Utc>>, duration: Option<Duration>, players: &[MatchPlayer]) -> String {
481    let mut miniprofiles: Vec<u32> = players.iter().filter_map(|p| p.miniprofile).collect();
482    miniprofiles.sort_unstable();
483    let players_part: String = miniprofiles.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
484
485    let id_parts = format!("{}{}{}{}", map.unwrap_or(""), time.map(|t| t.to_rfc3339()).unwrap_or_default(), duration.map(|d| d.num_seconds().to_string()).unwrap_or_default(), players_part,);
486
487    // Remove non-alphanumeric characters and commas are already alphanumeric-safe,
488    // but we keep the filter for the other fields.
489    let clean: String = id_parts.chars().filter(|c| c.is_alphanumeric() || *c == ',').collect();
490
491    let mut hasher = Sha256::new();
492    hasher.update(clean.as_bytes());
493    hex::encode(hasher.finalize())
494}