steam-user 0.1.0

Steam User web client for Rust - HTTP-based Steam Community interactions
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
//! Match history service for CS:GO/CS2 match data retrieval.

use std::{collections::HashMap, sync::OnceLock};

use chrono::{DateTime, Duration, NaiveDateTime, Utc};
use scraper::{Html, Selector};
use sha2::{Digest, Sha256};

use crate::{
    client::SteamUser,
    endpoint::steam_endpoint,
    error::SteamUserError,
    types::match_history::{Match, MatchHistoryResponse, MatchHistoryType, MatchPlayer, Team},
    utils::avatar::get_avatar_hash_from_url,
};

/// CS:GO/CS2 App ID.
const CSGO_APP_ID: u32 = 730;

static SEL_SCOREBOARD_TABLE: OnceLock<Selector> = OnceLock::new();
fn sel_scoreboard_table() -> &'static Selector {
    SEL_SCOREBOARD_TABLE.get_or_init(|| Selector::parse("table.csgo_scoreboard_inner_right").expect("valid CSS selector"))
}

static SEL_TR: OnceLock<Selector> = OnceLock::new();
fn sel_tr() -> &'static Selector {
    SEL_TR.get_or_init(|| Selector::parse("tr").expect("valid CSS selector"))
}

static SEL_TH: OnceLock<Selector> = OnceLock::new();
fn sel_th() -> &'static Selector {
    SEL_TH.get_or_init(|| Selector::parse("th").expect("valid CSS selector"))
}

static SEL_TD: OnceLock<Selector> = OnceLock::new();
fn sel_td() -> &'static Selector {
    SEL_TD.get_or_init(|| Selector::parse("td").expect("valid CSS selector"))
}

static SEL_LEFT_TABLE_TD: OnceLock<Selector> = OnceLock::new();
fn sel_left_table_td() -> &'static Selector {
    SEL_LEFT_TABLE_TD.get_or_init(|| Selector::parse("table.csgo_scoreboard_inner_left tr > td").expect("valid CSS selector"))
}

static SEL_ANCHOR: OnceLock<Selector> = OnceLock::new();
fn sel_anchor() -> &'static Selector {
    SEL_ANCHOR.get_or_init(|| Selector::parse("a").expect("valid CSS selector"))
}

static SEL_LINK_TITLE: OnceLock<Selector> = OnceLock::new();
fn sel_link_title() -> &'static Selector {
    SEL_LINK_TITLE.get_or_init(|| Selector::parse("a.linkTitle").expect("valid CSS selector"))
}

static SEL_PLAYER_AVATAR_IMG: OnceLock<Selector> = OnceLock::new();
fn sel_player_avatar_img() -> &'static Selector {
    SEL_PLAYER_AVATAR_IMG.get_or_init(|| Selector::parse(".playerAvatar a > img[src]").expect("valid CSS selector"))
}

impl SteamUser {
    /// Retrieves the CS:GO/CS2 match history for a specific mode and tab.
    ///
    /// # Arguments
    ///
    /// * `match_type` - The match history type to fetch.
    /// * `token` - Optional continuation token for pagination.
    ///
    /// # Returns
    ///
    /// Returns a [`MatchHistoryResponse`] containing the parsed matches and the
    /// next continuation token.
    #[steam_endpoint(GET, host = Community, path = "/profiles/{steam_id}/gcpd/730/", kind = Read)]
    pub async fn get_match_history(&self, match_type: MatchHistoryType, token: Option<&str>) -> Result<MatchHistoryResponse, SteamUserError> {
        match token {
            Some(t) if !t.is_empty() => self.get_paginated_match_history(match_type, t).await,
            _ => self.get_initial_match_history(match_type).await,
        }
    }

    /// Fetches the initial match history page (HTML response).
    #[steam_endpoint(GET, host = Community, path = "/profiles/{steam_id}/gcpd/{app_id}/", kind = Read)]
    #[tracing::instrument(skip(self))]
    async fn get_initial_match_history(&self, match_type: MatchHistoryType) -> Result<MatchHistoryResponse, SteamUserError> {
        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;

        // Initial request does not use ajax=1 according to user requirement
        let response = self.get_path(format!("/profiles/{}/gcpd/{}/", steam_id.steam_id64(), CSGO_APP_ID)).query(&[("tab", match_type.as_str())]).send().await?;
        self.check_response(&response)?;

        let text = response.text().await?;

        // Extract continue token + text first (cheap string finds), then hand
        // the body off to a blocking task for the heavy HTML parse + selector
        // traversal so the async runtime stays responsive.
        let continue_token = extract_between(&text, "var g_sGcContinueToken =", ";").map(|s| s.trim().trim_matches('\'').to_string()).unwrap_or_default();
        let continue_text = extract_between(&text, "load_more_button_continue_text\" class=\"returnLink\">", "</div>").map(|s| s.to_string()).unwrap_or_default();

        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}")))?;

        Ok(MatchHistoryResponse { continue_token, continue_text, matches })
    }

    /// Fetches subsequent match history pages using AJAX (JSON response).
    #[steam_endpoint(GET, host = Community, path = "/profiles/{steam_id}/gcpd/{app_id}", kind = Read)]
    #[tracing::instrument(skip(self))]
    async fn get_paginated_match_history(&self, match_type: MatchHistoryType, token: &str) -> Result<MatchHistoryResponse, SteamUserError> {
        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;

        // AJAX request uses ajax=1 and continue_token.
        // sessionid is automatically added by self.get()
        let params = vec![("ajax", "1"), ("tab", match_type.as_str()), ("continue_token", token)];

        let response = self.get_path(format!("/profiles/{}/gcpd/{}", steam_id.steam_id64(), CSGO_APP_ID)).query(&params).send().await?;
        self.check_response(&response)?;

        let json = response.json::<serde_json::Value>().await?;

        let continue_token = json.get("continue_token").and_then(|v| v.as_str()).unwrap_or("").to_string();
        let continue_text = json.get("continue_text").and_then(|v| v.as_str()).unwrap_or("").to_string();
        let html = json.get("html").and_then(|v| v.as_str()).unwrap_or("").to_string();

        // Heavy HTML parse runs on the blocking pool so the async runtime
        // stays responsive on large match-history responses.
        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}")))?;

        Ok(MatchHistoryResponse { continue_token, continue_text, matches })
    }
}

/// Helper to extract a substring between two delimiters.
fn extract_between<'a>(text: &'a str, start: &str, end: &str) -> Option<&'a str> {
    let start_idx = text.find(start)? + start.len();
    let rest = &text[start_idx..];
    let end_idx = rest.find(end)?;
    Some(&rest[..end_idx])
}

/// Parses match history HTML and returns a list of Match structs.
fn parse_match_history(html: &str, match_type: MatchHistoryType) -> Vec<Match> {
    if html.is_empty() {
        return Vec::new();
    }

    let document = Html::parse_document(html);
    let mut matches = Vec::new();

    // Select each match scoreboard table
    for table in document.select(sel_scoreboard_table()) {
        if let Some(m) = parse_single_match(&document, &table, match_type) {
            matches.push(m);
        }
    }

    matches
}

/// Parses a single match from its scoreboard table.
#[tracing::instrument(skip(_document, table))]
fn parse_single_match(_document: &Html, table: &scraper::ElementRef, match_type: MatchHistoryType) -> Option<Match> {
    // Local variables to collect match info (previously in MatchInfo struct)
    let mut map: Option<String> = None;
    let mut time: Option<DateTime<Utc>> = None;
    let mut timestamp: Option<i64> = None;
    let mut wait_time: Option<Duration> = None;
    let mut duration: Option<Duration> = None;
    let mut gotv_replay: Option<String> = None;
    let mut viewers: Option<i32> = None;
    let mut ranked = false;

    // Find parent tr element to get match info
    // We need to traverse up to find the match info table on the left side
    // This is a bit tricky with scraper, so we'll parse from the document

    // Get the table's HTML to find context
    let _table_html = table.html();

    // Try to find corresponding match info by looking at sibling elements
    // For now, we'll parse the player table and extract what we can

    let rows: Vec<_> = table.select(sel_tr()).collect();

    if rows.is_empty() {
        return None;
    }

    // First row should be headers
    let headers: Vec<String> = rows.first()?.select(sel_th()).map(|h| h.text().collect::<String>().trim().to_string()).collect();

    if headers.is_empty() {
        return None;
    }

    // Parse player rows
    let mut history_table: Vec<HashMap<String, String>> = Vec::new();
    let mut player_name_data: Vec<PlayerNameData> = Vec::new();

    for (i, row) in rows.iter().enumerate() {
        if i == 0 {
            continue; // Skip header row
        }

        let cells: Vec<_> = row.select(sel_td()).collect();
        let mut row_data = HashMap::new();

        for (j, cell) in cells.iter().enumerate() {
            if j >= headers.len() {
                continue;
            }
            let header = &headers[j];

            // Check if this is the player name column
            if cell.value().attr("class").unwrap_or("").contains("inner_name") {
                let name_data = parse_player_name_cell(cell);
                player_name_data.push(name_data.clone());
                row_data.insert(header.clone(), name_data.name);
            } else {
                row_data.insert(header.clone(), cell.text().collect::<String>().trim().to_string());
            }
        }

        history_table.push(row_data);
    }

    if history_table.is_empty() {
        return None;
    }

    // Find scoreboard row (middle row with score like "16 : 14")
    let scoreboard_index = history_table.len() / 2;
    if scoreboard_index < 1 || scoreboard_index >= history_table.len() {
        return None;
    }

    // Parse score from the scoreboard row
    let score_str = history_table.get(scoreboard_index)?.get("Player Name").cloned().unwrap_or_default();

    let scores: Vec<i32> = score_str.split(':').map(|s| s.trim().parse().unwrap_or(0)).collect();

    let ct_score = scores.first().copied().unwrap_or(0);
    let t_score = scores.get(1).copied().unwrap_or(0);

    // Parse players
    let mut players = Vec::new();
    let mut name_data_iter = player_name_data.iter();

    for (i, row) in history_table.iter().enumerate() {
        // Log HTML of the corresponding row (i+1 because rows[0] is the header)
        if let Some(html_row) = rows.get(i + 1) {
            tracing::trace!(row = i, html = %html_row.html(), "match history row");
        }

        if i == scoreboard_index {
            // Skip the scoreboard row - it doesn't have inner_name class,
            // so no entry was added to player_name_data for it
            continue;
        }

        let team = if i < scoreboard_index { Team::Ct } else { Team::T };
        let name_data = name_data_iter.next();

        let mut player = MatchPlayer { team, ..Default::default() };

        if let Some(nd) = name_data {
            player.name = nd.name.clone();
            player.link = nd.link.clone();
            player.miniprofile = nd.miniprofile;
            player.avatar_hash = nd.avatar_hash.clone();
            player.custom_url = nd.custom_url.clone();

            // Convert miniprofile (account ID) to steam ID 64
            if let Some(mp) = nd.miniprofile {
                player.steam_id = Some(steamid::SteamID::from_individual_account_id(mp));
            }
        }

        // Parse numeric stats
        player.ping = row.get("Ping").and_then(|s| s.parse().ok());
        player.kills = row.get("K").and_then(|s| s.parse().ok());
        player.assists = row.get("A").and_then(|s| s.parse().ok());
        player.deaths = row.get("D").and_then(|s| s.parse().ok());
        player.score = row.get("Score").and_then(|s| s.parse().ok());

        // Parse MVP stars
        if let Some(mvp_str) = row.get("") {
            player.mvp = parse_mvp(mvp_str);
        }

        // Parse HSP (headshot percentage)
        if let Some(hsp_str) = row.get("HSP") {
            player.hsp = hsp_str.trim().trim_end_matches('%').parse().ok();
        }

        // Validate player validity based on miniprofile (Steam Account ID) presence
        // This is much more reliable than checking for empty names or stats
        let has_valid_id = if let Some(nd) = &name_data { nd.miniprofile.is_some() } else { false };

        if !has_valid_id {
            // Only log strict warning if it looks like a ghost row that might be confusing
            if !player.name.is_empty() {
                tracing::warn!(player_name = %player.name, "skipping player row: no miniprofile (likely invalid/bot)");
            }
            continue;
        }

        players.push(player);
    }

    // Try to parse match info from the left-side table
    // This requires finding the parent structure
    // Try to parse match info from the left-side table
    // Traverse up to find the common parent (tr) that holds both left and right
    // tables potential structure: tr -> td -> table.csgo_scoreboard_inner_right
    // (current 'table') we want: tr -> td -> table.csgo_scoreboard_inner_left

    // Note: table.parent() returns NodeRef, so we need to wrap it back into
    // ElementRef to use select
    let container_row_node = table.parent().and_then(|td| td.parent());

    if let Some(row_node) = container_row_node {
        if let Some(row) = scraper::ElementRef::wrap(row_node) {
            // Scope selection to this specific row
            let left_tds = row.select(sel_left_table_td());

            for td in left_tds {
                let text = td.text().collect::<String>().trim().to_string();

                if text.starts_with("Competitive ") {
                    map = Some(text.strip_prefix("Competitive ").unwrap_or(&text).to_string());
                } else if text.starts_with("Premier ") {
                    map = Some(text.strip_prefix("Premier ").unwrap_or(&text).to_string());
                } else if text.ends_with(" GMT") {
                    // Parse timestamp
                    if let Some(dt) = parse_match_timestamp(&text) {
                        time = Some(dt);
                        timestamp = Some(dt.timestamp_millis());
                    }
                } else if text.starts_with("Wait Time: ") {
                    wait_time = text.strip_prefix("Wait Time: ").and_then(|s| parse_duration(s.trim()));
                } else if text.starts_with("Match Duration: ") {
                    duration = text.strip_prefix("Match Duration: ").and_then(|s| parse_duration(s.trim()));
                } else if text == "Download GOTV Replay" || text == "Download Replay" || text == "Tải bản phát lại" {
                    // Find the link
                    if let Some(a) = td.select(sel_anchor()).next() {
                        gotv_replay = a.value().attr("href").map(|s| s.to_string());
                    }
                } else if text.starts_with("Viewers: ") {
                    viewers = text.strip_prefix("Viewers: ").and_then(|s| s.parse().ok());
                } else if text.starts_with("Ranked: Yes") {
                    ranked = true;
                }
            }
        }
    }

    // Generate match hash
    let match_hash = generate_match_hash(map.as_deref(), time, duration, &players);

    Some(Match {
        match_hash,
        map,
        time,
        timestamp,
        wait_time,
        duration,
        gotv_replay,
        viewers,
        ranked,
        players,
        scoreboard: [ct_score, t_score],
        match_type,
    })
}

/// Player name data extracted from the name cell.
#[derive(Debug, Clone, Default)]
struct PlayerNameData {
    name: String,
    link: Option<String>,
    miniprofile: Option<u32>,
    avatar_hash: Option<String>,
    custom_url: Option<String>,
}

/// Parses the player name cell to extract name, link, miniprofile, and avatar.
fn parse_player_name_cell(cell: &scraper::ElementRef) -> PlayerNameData {
    let mut data = PlayerNameData::default();

    // Find the link - this is where the actual player name is stored
    if let Some(a) = cell.select(sel_link_title()).next() {
        // Get the name directly from the anchor element (most reliable)
        data.name = a.text().collect::<String>().trim().to_string();
        data.link = a.value().attr("href").map(|s| s.to_string());
        data.miniprofile = a.value().attr("data-miniprofile").and_then(|s| s.parse().ok());

        if data.miniprofile.is_none() {
            tracing::warn!(player_name = %data.name, "failed to parse miniprofile for player");
            if let Some(mp_str) = a.value().attr("data-miniprofile") {
                tracing::warn!(data_miniprofile = %mp_str, "data-miniprofile attribute present but unparseable");
            } else {
                tracing::warn!("data-miniprofile attribute missing");
            }
        }

        // Extract custom URL from link
        if let Some(ref link) = data.link {
            data.custom_url = get_custom_url_from_profile_url(link);
        }
    } else {
        tracing::warn!(player_name = %data.name, "no profile link found for player");
    }

    // Find avatar
    if let Some(img) = cell.select(sel_player_avatar_img()).next() {
        if let Some(src) = img.value().attr("src") {
            data.avatar_hash = get_avatar_hash_from_url(src);
        }
    }

    data
}

// get_avatar_hash_from_url is imported from crate::utils::avatar

/// Extracts custom URL from profile URL.
fn get_custom_url_from_profile_url(url: &str) -> Option<String> {
    if url.contains("/id/") {
        url.split("/id/").nth(1).map(|s| s.trim_end_matches('/').to_string())
    } else {
        None
    }
}

/// Parses match timestamp string to DateTime<Utc>.
fn parse_match_timestamp(time_str: &str) -> Option<DateTime<Utc>> {
    // Format: "2024-01-01 12:00:00 GMT"
    let clean = time_str.replace(" GMT", "").trim().to_string();
    NaiveDateTime::parse_from_str(&clean, "%Y-%m-%d %H:%M:%S").ok().map(|ndt| DateTime::from_naive_utc_and_offset(ndt, Utc))
}

/// Parses a duration string like "MM:SS" or "HH:MM:SS" into chrono::Duration.
fn parse_duration(s: &str) -> Option<Duration> {
    let parts: Vec<&str> = s.split(':').collect();
    match parts.len() {
        2 => {
            // MM:SS
            let mins: i64 = parts[0].parse().ok()?;
            let secs: i64 = parts[1].parse().ok()?;
            Some(Duration::minutes(mins) + Duration::seconds(secs))
        }
        3 => {
            // HH:MM:SS
            let hours: i64 = parts[0].parse().ok()?;
            let mins: i64 = parts[1].parse().ok()?;
            let secs: i64 = parts[2].parse().ok()?;
            Some(Duration::hours(hours) + Duration::minutes(mins) + Duration::seconds(secs))
        }
        _ => None,
    }
}

/// Parses MVP string to count.
fn parse_mvp(mvp_str: &str) -> Option<i32> {
    let trimmed = mvp_str.trim();
    if trimmed.is_empty() {
        Some(0)
    } else if trimmed == "" {
        Some(1)
    } else if trimmed.starts_with("") {
        trimmed.replace("", "").trim().parse().ok()
    } else {
        Some(0)
    }
}

/// Generates a unique match hash from match info.
///
/// Player miniprofiles (sorted) are included so that matches with partial
/// parse failures (all-None metadata) still produce distinct hashes instead
/// of all colliding on `sha256("")`.
fn generate_match_hash(map: Option<&str>, time: Option<DateTime<Utc>>, duration: Option<Duration>, players: &[MatchPlayer]) -> String {
    let mut miniprofiles: Vec<u32> = players.iter().filter_map(|p| p.miniprofile).collect();
    miniprofiles.sort_unstable();
    let players_part: String = miniprofiles.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");

    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,);

    // Remove non-alphanumeric characters and commas are already alphanumeric-safe,
    // but we keep the filter for the other fields.
    let clean: String = id_parts.chars().filter(|c| c.is_alphanumeric() || *c == ',').collect();

    let mut hasher = Sha256::new();
    hasher.update(clean.as_bytes());
    hex::encode(hasher.finalize())
}