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,
};
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 {
#[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,
}
}
#[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)?;
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?;
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 })
}
#[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)?;
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(¶ms).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();
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 })
}
}
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])
}
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();
for table in document.select(sel_scoreboard_table()) {
if let Some(m) = parse_single_match(&document, &table, match_type) {
matches.push(m);
}
}
matches
}
#[tracing::instrument(skip(_document, table))]
fn parse_single_match(_document: &Html, table: &scraper::ElementRef, match_type: MatchHistoryType) -> Option<Match> {
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;
let _table_html = table.html();
let rows: Vec<_> = table.select(sel_tr()).collect();
if rows.is_empty() {
return None;
}
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;
}
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; }
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];
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;
}
let scoreboard_index = history_table.len() / 2;
if scoreboard_index < 1 || scoreboard_index >= history_table.len() {
return None;
}
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);
let mut players = Vec::new();
let mut name_data_iter = player_name_data.iter();
for (i, row) in history_table.iter().enumerate() {
if let Some(html_row) = rows.get(i + 1) {
tracing::trace!(row = i, html = %html_row.html(), "match history row");
}
if i == scoreboard_index {
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();
if let Some(mp) = nd.miniprofile {
player.steam_id = Some(steamid::SteamID::from_individual_account_id(mp));
}
}
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());
if let Some(mvp_str) = row.get("★") {
player.mvp = parse_mvp(mvp_str);
}
if let Some(hsp_str) = row.get("HSP") {
player.hsp = hsp_str.trim().trim_end_matches('%').parse().ok();
}
let has_valid_id = if let Some(nd) = &name_data { nd.miniprofile.is_some() } else { false };
if !has_valid_id {
if !player.name.is_empty() {
tracing::warn!(player_name = %player.name, "skipping player row: no miniprofile (likely invalid/bot)");
}
continue;
}
players.push(player);
}
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) {
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") {
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" {
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;
}
}
}
}
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,
})
}
#[derive(Debug, Clone, Default)]
struct PlayerNameData {
name: String,
link: Option<String>,
miniprofile: Option<u32>,
avatar_hash: Option<String>,
custom_url: Option<String>,
}
fn parse_player_name_cell(cell: &scraper::ElementRef) -> PlayerNameData {
let mut data = PlayerNameData::default();
if let Some(a) = cell.select(sel_link_title()).next() {
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");
}
}
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");
}
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
}
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
}
}
fn parse_match_timestamp(time_str: &str) -> Option<DateTime<Utc>> {
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))
}
fn parse_duration(s: &str) -> Option<Duration> {
let parts: Vec<&str> = s.split(':').collect();
match parts.len() {
2 => {
let mins: i64 = parts[0].parse().ok()?;
let secs: i64 = parts[1].parse().ok()?;
Some(Duration::minutes(mins) + Duration::seconds(secs))
}
3 => {
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,
}
}
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)
}
}
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,);
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())
}