1use 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
17const 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 #[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 #[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 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 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 #[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 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(¶ms).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 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
130fn 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
138fn 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 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#[tracing::instrument(skip(_document, table))]
159fn parse_single_match(_document: &Html, table: &scraper::ElementRef, match_type: MatchHistoryType) -> Option<Match> {
160 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 let _table_html = table.html();
176
177 let rows: Vec<_> = table.select(sel_tr()).collect();
181
182 if rows.is_empty() {
183 return None;
184 }
185
186 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 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; }
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 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 let scoreboard_index = history_table.len() / 2;
230 if scoreboard_index < 1 || scoreboard_index >= history_table.len() {
231 return None;
232 }
233
234 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 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 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 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 if let Some(mp) = nd.miniprofile {
272 player.steam_id = Some(steamid::SteamID::from_individual_account_id(mp));
273 }
274 }
275
276 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 if let Some(mvp_str) = row.get("★") {
285 player.mvp = parse_mvp(mvp_str);
286 }
287
288 if let Some(hsp_str) = row.get("HSP") {
290 player.hsp = hsp_str.trim().trim_end_matches('%').parse().ok();
291 }
292
293 let has_valid_id = if let Some(nd) = &name_data { nd.miniprofile.is_some() } else { false };
296
297 if !has_valid_id {
298 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 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 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 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 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 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#[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
384fn parse_player_name_cell(cell: &scraper::ElementRef) -> PlayerNameData {
386 let mut data = PlayerNameData::default();
387
388 if let Some(a) = cell.select(sel_link_title()).next() {
390 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 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 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
422fn 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
433fn parse_match_timestamp(time_str: &str) -> Option<DateTime<Utc>> {
435 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
440fn parse_duration(s: &str) -> Option<Duration> {
442 let parts: Vec<&str> = s.split(':').collect();
443 match parts.len() {
444 2 => {
445 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 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
461fn 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
475fn 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 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}