This commit is contained in:
Robin Appelman 2023-11-18 17:11:54 +01:00
commit 53cc7822c4
26 changed files with 31748 additions and 73 deletions

24
Cargo.lock generated
View file

@ -607,29 +607,6 @@ version = "2.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167"
[[package]]
name = "miette"
version = "5.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e"
dependencies = [
"miette-derive",
"once_cell",
"thiserror",
"unicode-width",
]
[[package]]
name = "miette-derive"
version = "5.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.39",
]
[[package]]
name = "mime"
version = "0.3.17"
@ -1566,7 +1543,6 @@ version = "0.1.0"
dependencies = [
"insta",
"main_error",
"miette",
"reqwest",
"scraper",
"serde",

View file

@ -3,12 +3,12 @@ name = "ugc-scraper"
version = "0.1.0"
edition = "2021"
rust-version = "1.67.0"
description = "Scraper for ugcleague.com"
[dependencies]
tokio = "1.34.0"
reqwest = "0.11.22"
scraper = "0.18.1"
miette = "5.10.0"
thiserror = "1.0.50"
time = { version = "0.3.30", features = ["parsing", "macros"] }
steamid-ng = "1.0.0"

25
README.md Normal file
View file

@ -0,0 +1,25 @@
# ugc-scraper
*We have ugc api at home*
## Usage
```rust
use ugc_scraper::{Result, SteamID, UgcClient};
#[tokio::main]
async fn main() -> Result<()> {
let client = UgcClient::new();
let id = SteamID::from(76561198024494988);
let player = client.player(id).await?;
println!("{}", player.name);
for team in player.teams {
println!(
" {} playing {} since {}",
team.team.name, team.league, team.since
)
}
Ok(())
}
```

33
examples/player.rs Normal file
View file

@ -0,0 +1,33 @@
use main_error::MainResult;
use std::env::args;
use steamid_ng::SteamID;
use ugc_scraper::UgcClient;
#[tokio::main]
async fn main() -> MainResult {
let client = UgcClient::new();
let id = args().nth(1).expect("no steam id provided");
let id = SteamID::try_from(id.as_str()).expect("invalid steam id provided");
let player = client.player(id).await?;
println!("{}", player.name);
for team in player.teams {
println!(
" {} playing {} since {}",
team.team.name, team.league, team.since
)
}
println!();
println!("previous teams:");
let membership = client.player_team_history(id).await?;
for team in membership {
if let Some(left) = team.left {
println!(
" {} in {} from {} till {}",
team.team.name, team.division, team.joined, left
);
}
}
Ok(())
}

17
examples/readme.rs Normal file
View file

@ -0,0 +1,17 @@
use ugc_scraper::{Result, SteamID, UgcClient};
#[tokio::main]
async fn main() -> Result<()> {
let client = UgcClient::new();
let id = SteamID::from(76561198024494988);
let player = client.player(id).await?;
println!("{}", player.name);
for team in player.teams {
println!(
" {} playing {} since {}",
team.team.name, team.league, team.since
)
}
Ok(())
}

45
examples/team.rs Normal file
View file

@ -0,0 +1,45 @@
use main_error::MainResult;
use std::env::args;
use ugc_scraper::UgcClient;
#[tokio::main]
async fn main() -> MainResult {
let client = UgcClient::new();
let id = args().nth(1).expect("no team id provided");
let id = id.parse().expect("invalid team id provided");
let team = client.team(id).await?;
println!("{} - {}", team.tag, team.name);
println!("playing {} in {}", team.format, team.division);
println!();
println!("with: ");
for member in team.members {
println!(" {} since {}", member.name, member.since);
}
println!();
println!("previous players ");
let roster_history = client.team_roster_history(id).await?;
for roster_item in roster_history {
if let Some(left) = roster_item.left {
println!(
" {} joined at {} and left at {}",
roster_item.name, roster_item.joined, left
);
}
}
println!();
println!("name changes:");
for name_change in team.name_changes {
println!(
" {} - {} to {} - {} at {}",
name_change.from_tag,
name_change.from,
name_change.to_tag,
name_change.to,
name_change.date
);
}
Ok(())
}

View file

@ -1,18 +0,0 @@
use main_error::MainResult;
use std::env::args;
use steamid_ng::SteamID;
use ugc_scraper::UgcClient;
#[tokio::main]
async fn main() -> MainResult {
let client = UgcClient::new();
let id = args().nth(1).expect("no steam id provided");
let id = SteamID::try_from(id.as_str()).expect("invalid steam id provided");
let player = client.player(id).await?;
dbg!(player.teams);
let membership = client.player_team_history(id).await?;
dbg!(membership);
Ok(())
}

View file

@ -1,5 +1,5 @@
use steamid_ng::SteamID;
use time::Date;
pub use steamid_ng::SteamID;
use time::{Date, OffsetDateTime};
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
@ -42,3 +42,92 @@ pub struct MembershipHistory {
pub joined: Date,
pub left: Option<Date>,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Team {
pub name: String,
pub tag: String,
pub image: String,
pub format: String,
pub timezone: String,
pub division: String,
pub description: String,
pub titles: Vec<String>,
pub members: Vec<Membership>,
pub results: Vec<Record>,
pub name_changes: Vec<NameChange>,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct NameChange {
pub from_tag: String,
pub from: String,
pub to_tag: String,
pub to: String,
pub date: Date,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Membership {
pub name: String,
pub steam_id: SteamID,
pub role: String,
pub since: OffsetDateTime,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Record {
pub season: u32,
pub division: String,
pub wins: u8,
pub losses: u8,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct RosterHistory {
pub name: String,
pub steam_id: SteamID,
pub joined: Date,
pub left: Option<Date>,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct TeamSeason {
pub season: u32,
pub matches: Vec<TeamSeasonMatch>,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct TeamSeasonMatch {
pub division: String,
pub week: u8,
pub date: String,
pub side: String,
pub result: MatchResult,
pub map: String,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum MatchResult {
Played {
opponent: TeamRef,
score: u8,
score_opponent: u8,
match_points: f32,
match_points_opponent: f32,
},
Pending {
opponent: TeamRef,
score: u8,
score_opponent: u8,
},
ByeWeek,
}

View file

@ -1,16 +1,14 @@
use miette::Diagnostic;
use thiserror::Error;
#[derive(Debug, Error, Diagnostic)]
#[derive(Debug, Error)]
pub enum ScrapeError {
#[error("Failed to request data: {0:#}")]
Request(#[from] reqwest::Error),
#[error(transparent)]
#[diagnostic(transparent)]
Parse(#[from] ParseError),
}
#[derive(Debug, Error, Diagnostic, Clone)]
#[derive(Debug, Error, Clone)]
pub enum ParseError {
#[error("Couldn't find expected element '{selector}' for {role}")]
ElementNotFound {
@ -22,6 +20,8 @@ pub enum ParseError {
selector: &'static str,
role: &'static str,
},
#[error("Invalid text for {role}: {text}")]
InvalidText { text: String, role: &'static str },
#[error("Invalid link for {role}: {link}")]
InvalidLink { link: String, role: &'static str },
#[error("Invalid date for {role}: {date}")]

View file

@ -3,11 +3,14 @@ mod error;
#[doc(hidden)]
pub mod parser;
use crate::data::{MembershipHistory, Player};
use crate::parser::{Parser, PlayerDetailsParser, PlayerParser};
use crate::data::{MembershipHistory, Player, RosterHistory, Team, TeamSeason};
use crate::parser::{
Parser, PlayerDetailsParser, PlayerParser, TeamMatchesParser, TeamParser,
TeamRosterHistoryParser,
};
pub use error::*;
use reqwest::Client;
use steamid_ng::SteamID;
pub use steamid_ng::SteamID;
pub type Result<T, E = ScrapeError> = std::result::Result<T, E>;
@ -16,17 +19,25 @@ pub struct UgcClient {
client: Client,
player_parser: PlayerParser,
player_detail_parser: PlayerDetailsParser,
team_parser: TeamParser,
team_roster_history_parser: TeamRosterHistoryParser,
team_matches_parser: TeamMatchesParser,
}
/// "API client" for ugc by scraping the website
impl UgcClient {
pub fn new() -> Self {
UgcClient {
client: Client::default(),
player_parser: PlayerParser::new(),
player_detail_parser: PlayerDetailsParser::new(),
team_parser: TeamParser::new(),
team_roster_history_parser: TeamRosterHistoryParser::new(),
team_matches_parser: TeamMatchesParser::new(),
}
}
/// Retrieve player information
pub async fn player(&self, steam_id: SteamID) -> Result<Player> {
let body = self
.client
@ -41,6 +52,7 @@ impl UgcClient {
self.player_parser.parse(&body)
}
/// Retrieve team membership history for a player
pub async fn player_team_history(&self, steam_id: SteamID) -> Result<Vec<MembershipHistory>> {
let body = self
.client
@ -54,4 +66,49 @@ impl UgcClient {
.await?;
self.player_detail_parser.parse(&body)
}
/// Retrieve team information
pub async fn team(&self, id: u32) -> Result<Team> {
let body = self
.client
.get(&format!(
"https://www.ugcleague.com/team_page.cfm?clan_id={}",
id
))
.send()
.await?
.text()
.await?;
self.team_parser.parse(&body)
}
/// Retrieve team roster history
pub async fn team_roster_history(&self, id: u32) -> Result<Vec<RosterHistory>> {
let body = self
.client
.get(&format!(
"https://www.ugcleague.com/team_page_rosterhistory.cfm?clan_id={}",
id
))
.send()
.await?
.text()
.await?;
self.team_roster_history_parser.parse(&body)
}
/// Retrieve team match history
pub async fn team_matches(&self, id: u32) -> Result<Vec<TeamSeason>> {
let body = self
.client
.get(&format!(
"https://www.ugcleague.com/team_page_matches.cfm?clan_id={}",
id
))
.send()
.await?
.text()
.await?;
self.team_matches_parser.parse(&body)
}
}

View file

@ -1,13 +1,20 @@
use crate::{ParseError, Result};
use scraper::{ElementRef, Selector};
use steamid_ng::SteamID;
use time::format_description::FormatItem;
use time::macros::format_description;
mod player;
mod player_details;
mod team;
mod team_matches;
mod team_roster_history;
pub use player::*;
pub use player_details::*;
pub use team::*;
pub use team_matches::*;
pub use team_roster_history::*;
pub trait Parser {
type Output;
@ -47,6 +54,11 @@ fn select_last_text<'a>(el: ElementRef<'a>, selector: &Selector) -> Option<&'a s
const DATE_FORMAT: &[FormatItem<'static>] =
format_description!("[month padding:none]/[day padding:none]/[year]");
const MEMBER_DATE_FORMAT: &[FormatItem<'static>] = format_description!(
"[month repr:short] [day padding:none], [year]\n/\n[hour padding:none]:[minute] [period]\n(ET)"
);
const ROSTER_HISTORY_DATE_FORMAT: &[FormatItem<'static>] =
format_description!("[month repr:short] [day padding:none], [year]");
fn team_id_from_link(link: &str) -> Result<u32, ParseError> {
link.rsplit_once('=')
@ -56,3 +68,13 @@ fn team_id_from_link(link: &str) -> Result<u32, ParseError> {
role: "team id",
})
}
fn steam_id_from_link(link: &str) -> Result<SteamID, ParseError> {
link.rsplit_once('=')
.and_then(|part| part.1.parse::<u64>().ok())
.ok_or_else(|| ParseError::InvalidLink {
link: link.to_string(),
role: "user id",
})
.map(SteamID::from)
}

341
src/parser/team.rs Normal file
View file

@ -0,0 +1,341 @@
use super::{ElementExt, Parser};
use crate::data::{Membership, NameChange, Record, Team};
use crate::parser::{select_text, steam_id_from_link, DATE_FORMAT, MEMBER_DATE_FORMAT};
use crate::{ParseError, Result};
use scraper::{Html, Selector};
use time::{Date, PrimitiveDateTime, UtcOffset};
const SELECTOR_TEAM_NAME: &str = ".container .col-md-12 h1 > b";
const SELECTOR_TEAM_TAG: &str = ".container .col-md-12 h1 > span";
const SELECTOR_TEAM_IMAGE: &str = ".container .col-md-12 a > img";
const SELECTOR_TEAM_FORMAT: &str = ".container .col-md-3 .white-row-small h5 .text-danger b";
const SELECTOR_TEAM_DIVISION: &str = ".container .col-md-3 .white-row-small h5 > b";
const SELECTOR_TEAM_TIMEZONE: &str = ".container .col-md-3 .white-row-small p > small > b";
const SELECTOR_TEAM_DESCRIPTION: &str =
".container .col-md-3 .white-row-small p:nth-child(4) > small";
const SELECTOR_TEAM_TITLES: &str = ".container .col-md-3 .white-row-small p > .text-warning";
const SELECTOR_TEAM_MEMBER_ROW: &str =
".container .white-row-small > .row-fluid > .col-md-12 > .white-row-light-small";
const SELECTOR_TEAM_MEMBER_LINK: &str = "b > a[href^=\"players_page\"]";
const SELECTOR_TEAM_MEMBER_ROLE: &str = ".tinytext";
const SELECTOR_TEAM_MEMBER_SINCE: &str = ".tinytext > em";
const SELECTOR_TEAM_RECORDS: &str =
".container .col-md-3 .white-row-small .table-responsive > table tbody tr";
const SELECTOR_TEAM_RECORD_SEASON: &str = "td:nth-child(1) small span b";
const SELECTOR_TEAM_RECORD_DIVISION: &str = "td:nth-child(2) small";
const SELECTOR_TEAM_RECORD_RESULT: &str = "td:nth-child(3)";
const SELECTOR_TEAM_NAME_CHANGE: &str =
".white-row-small:nth-child(3) .table-responsive table tbody tr";
const SELECTOR_TEAM_NAME_FROM_TAG: &str = "td:nth-child(1) small";
const SELECTOR_TEAM_NAME_FROM_NAME: &str = "td:nth-child(2) small";
const SELECTOR_TEAM_NAME_TO_TAG: &str = "td:nth-child(3) small";
const SELECTOR_TEAM_NAME_TO_NAME: &str = "td:nth-child(4) small";
const SELECTOR_TEAM_NAME_DATE: &str = "td:nth-child(5) small";
pub struct TeamParser {
selector_name: Selector,
selector_tag: Selector,
selector_image: Selector,
selector_team_format: Selector,
selector_team_division: Selector,
selector_team_timezone: Selector,
selector_team_description: Selector,
selector_team_titles: Selector,
selector_team_member_row: Selector,
selector_team_member_link: Selector,
selector_team_member_role: Selector,
selector_team_member_since: Selector,
selector_team_records: Selector,
selector_team_record_season: Selector,
selector_team_record_division: Selector,
selector_team_record_result: Selector,
selector_team_name_item: Selector,
selector_team_name_from_tag: Selector,
selector_team_name_from_name: Selector,
selector_team_name_to_tag: Selector,
selector_team_name_to_name: Selector,
selector_team_name_date: Selector,
}
impl Default for TeamParser {
fn default() -> Self {
Self::new()
}
}
impl TeamParser {
pub fn new() -> Self {
TeamParser {
selector_name: Selector::parse(SELECTOR_TEAM_NAME).unwrap(),
selector_tag: Selector::parse(SELECTOR_TEAM_TAG).unwrap(),
selector_image: Selector::parse(SELECTOR_TEAM_IMAGE).unwrap(),
selector_team_format: Selector::parse(SELECTOR_TEAM_FORMAT).unwrap(),
selector_team_division: Selector::parse(SELECTOR_TEAM_DIVISION).unwrap(),
selector_team_timezone: Selector::parse(SELECTOR_TEAM_TIMEZONE).unwrap(),
selector_team_description: Selector::parse(SELECTOR_TEAM_DESCRIPTION).unwrap(),
selector_team_titles: Selector::parse(SELECTOR_TEAM_TITLES).unwrap(),
selector_team_member_row: Selector::parse(SELECTOR_TEAM_MEMBER_ROW).unwrap(),
selector_team_member_link: Selector::parse(SELECTOR_TEAM_MEMBER_LINK).unwrap(),
selector_team_member_role: Selector::parse(SELECTOR_TEAM_MEMBER_ROLE).unwrap(),
selector_team_member_since: Selector::parse(SELECTOR_TEAM_MEMBER_SINCE).unwrap(),
selector_team_records: Selector::parse(SELECTOR_TEAM_RECORDS).unwrap(),
selector_team_record_season: Selector::parse(SELECTOR_TEAM_RECORD_SEASON).unwrap(),
selector_team_record_division: Selector::parse(SELECTOR_TEAM_RECORD_DIVISION).unwrap(),
selector_team_record_result: Selector::parse(SELECTOR_TEAM_RECORD_RESULT).unwrap(),
selector_team_name_item: Selector::parse(SELECTOR_TEAM_NAME_CHANGE).unwrap(),
selector_team_name_from_tag: Selector::parse(SELECTOR_TEAM_NAME_FROM_TAG).unwrap(),
selector_team_name_from_name: Selector::parse(SELECTOR_TEAM_NAME_FROM_NAME).unwrap(),
selector_team_name_to_tag: Selector::parse(SELECTOR_TEAM_NAME_TO_TAG).unwrap(),
selector_team_name_to_name: Selector::parse(SELECTOR_TEAM_NAME_TO_NAME).unwrap(),
selector_team_name_date: Selector::parse(SELECTOR_TEAM_NAME_DATE).unwrap(),
}
}
}
impl Parser for TeamParser {
type Output = Team;
fn parse(&self, document: &str) -> Result<Self::Output> {
let document = Html::parse_document(document);
let root = document.root_element();
let name = select_text(root, &self.selector_name)
.ok_or(ParseError::ElementNotFound {
selector: SELECTOR_TEAM_NAME,
role: "team name",
})?
.to_string();
let tag = select_text(root, &self.selector_tag)
.ok_or(ParseError::ElementNotFound {
selector: SELECTOR_TEAM_TAG,
role: "team tag",
})?
.to_string();
let image =
document
.select(&self.selector_image)
.next()
.ok_or(ParseError::ElementNotFound {
selector: SELECTOR_TEAM_IMAGE,
role: "team image",
})?;
let image = image
.attr("data-cfsrc")
.or_else(|| image.attr("src"))
.unwrap_or_default()
.to_string();
let format = select_text(root, &self.selector_team_format)
.ok_or(ParseError::ElementNotFound {
selector: SELECTOR_TEAM_FORMAT,
role: "team format",
})?
.to_string();
let division = select_text(root, &self.selector_team_division)
.ok_or(ParseError::ElementNotFound {
selector: SELECTOR_TEAM_DIVISION,
role: "team division",
})?
.to_string();
let timezone = select_text(root, &self.selector_team_timezone)
.ok_or(ParseError::ElementNotFound {
selector: SELECTOR_TEAM_TIMEZONE,
role: "team timzone",
})?
.to_string();
let description = select_text(root, &self.selector_team_description)
.ok_or(ParseError::ElementNotFound {
selector: SELECTOR_TEAM_DESCRIPTION,
role: "team description",
})?
.replace('\n', " ");
let titles = document
.select(&self.selector_team_titles)
.next()
.ok_or(ParseError::ElementNotFound {
selector: SELECTOR_TEAM_TITLES,
role: "team titles",
})?
.text()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
let results = document
.select(&self.selector_team_records)
.map(|record| {
let season = select_text(record, &self.selector_team_record_season).ok_or(
ParseError::ElementNotFound {
selector: SELECTOR_TEAM_RECORD_SEASON,
role: "team record season",
},
)?;
let division = select_text(record, &self.selector_team_record_division)
.ok_or(ParseError::ElementNotFound {
selector: SELECTOR_TEAM_RECORD_DIVISION,
role: "team record division",
})?
.to_string();
let result = select_text(record, &self.selector_team_record_result).ok_or(
ParseError::ElementNotFound {
selector: SELECTOR_TEAM_RECORD_RESULT,
role: "team record result",
},
)?;
let (wins, losses) =
result
.split_once('-')
.ok_or_else(|| ParseError::InvalidText {
text: result.to_string(),
role: "team record result",
})?;
Ok(Record {
season: season.parse().map_err(|_| ParseError::InvalidText {
text: season.to_string(),
role: "team record season",
})?,
division,
wins: wins.parse().map_err(|_| ParseError::InvalidText {
text: wins.to_string(),
role: "team record wins",
})?,
losses: losses.parse().map_err(|_| ParseError::InvalidText {
text: losses.to_string(),
role: "team record losses",
})?,
})
})
.collect::<Result<Vec<_>>>()?;
let members = document
.select(&self.selector_team_member_row)
.map(|row| {
let link = row.select(&self.selector_team_member_link).next().ok_or(
ParseError::ElementNotFound {
selector: SELECTOR_TEAM_MEMBER_LINK,
role: "team member link",
},
)?;
let name = link
.first_text()
.ok_or(ParseError::EmptyText {
selector: SELECTOR_TEAM_MEMBER_LINK,
role: "team member link",
})?
.to_string();
let link = link.attr("href").unwrap_or_default();
let role = select_text(row, &self.selector_team_member_role)
.ok_or(ParseError::ElementNotFound {
selector: SELECTOR_TEAM_MEMBER_ROLE,
role: "team member role",
})?
.split('\n')
.next()
.unwrap();
let since = select_text(row, &self.selector_team_member_since).ok_or(
ParseError::ElementNotFound {
selector: SELECTOR_TEAM_MEMBER_SINCE,
role: "team member since",
},
)?;
let role = role.trim().to_string();
let since = since.trim();
let since = PrimitiveDateTime::parse(since, MEMBER_DATE_FORMAT)
.map_err(|_| ParseError::InvalidDate {
role: "member join date",
date: since.to_string(),
})?
.assume_offset(UtcOffset::from_hms(-5, 0, 0).unwrap());
Ok(Membership {
name,
steam_id: steam_id_from_link(link)?,
role,
since,
})
})
.collect::<Result<Vec<_>>>()?;
let name_changes = document
.select(&self.selector_team_name_item)
.map(|row| {
let from_tag = select_text(row, &self.selector_team_name_from_tag).ok_or(
ParseError::ElementNotFound {
selector: SELECTOR_TEAM_NAME_FROM_TAG,
role: "team name change from tag",
},
)?;
let from_name = select_text(row, &self.selector_team_name_from_name).ok_or(
ParseError::ElementNotFound {
selector: SELECTOR_TEAM_NAME_FROM_NAME,
role: "team name change from name",
},
)?;
let to_tag = select_text(row, &self.selector_team_name_to_tag).ok_or(
ParseError::ElementNotFound {
selector: SELECTOR_TEAM_NAME_TO_TAG,
role: "team name change to tag",
},
)?;
let to_name = select_text(row, &self.selector_team_name_to_name).ok_or(
ParseError::ElementNotFound {
selector: SELECTOR_TEAM_NAME_TO_NAME,
role: "team name change from name",
},
)?;
let date = select_text(row, &self.selector_team_name_date).ok_or(
ParseError::ElementNotFound {
selector: SELECTOR_TEAM_NAME_DATE,
role: "team name change date",
},
)?;
let date = Date::parse(date, DATE_FORMAT).map_err(|_| ParseError::InvalidDate {
date: date.to_string(),
role: "team name change date",
})?;
Ok(NameChange {
from_tag: from_tag.to_string(),
from: from_name.to_string(),
to_tag: to_tag.to_string(),
to: to_name.to_string(),
date,
})
})
.collect::<Result<_>>()?;
Ok(Team {
name,
description,
division,
timezone,
format,
image,
tag,
titles,
results,
members,
name_changes,
})
}
}

212
src/parser/team_matches.rs Normal file
View file

@ -0,0 +1,212 @@
use super::Parser;
use crate::data::{MatchResult, TeamRef, TeamSeason, TeamSeasonMatch};
use crate::parser::{select_text, team_id_from_link, ElementExt};
use crate::{ParseError, Result};
use scraper::{Html, Selector};
const SELECTOR_SEASON_TITLE: &str =
".container table.table.table-condensed.table-striped thead h4 b";
const SELECTOR_SEASON_MATCHES: &str =
".container table.table.table-condensed.table-striped tbody:nth-child(3n)";
const SELECTOR_SEASON_MATCH: &str = "tr:not(:last-child)";
const SELECTOR_SEASON_DIVISION: &str = "td:nth-child(1) small";
const SELECTOR_SEASON_WEEK: &str = "td:nth-child(2) small";
const SELECTOR_SEASON_DATE: &str = "td:nth-child(3) small";
const SELECTOR_SEASON_SIDE: &str = "td:nth-child(4) small";
const SELECTOR_SEASON_OPPONENT: &str = "td:nth-child(6) a";
const SELECTOR_SEASON_MAP: &str = "td:nth-child(7)";
const SELECTOR_SEASON_SCORES: &str = "td:nth-child(8)";
const SELECTOR_SEASON_POINTS: &str = "td:nth-child(9) small";
const SELECTOR_SEASON_POINTS_OPPONENTS: &str = "td:nth-child(10) small";
pub struct TeamMatchesParser {
selector_title: Selector,
selector_matches: Selector,
selector_match: Selector,
selector_division: Selector,
selector_week: Selector,
selector_date: Selector,
selector_side: Selector,
selector_opponent: Selector,
selector_map: Selector,
selector_scores: Selector,
selector_points: Selector,
selector_points_opponent: Selector,
}
impl Default for TeamMatchesParser {
fn default() -> Self {
Self::new()
}
}
impl TeamMatchesParser {
pub fn new() -> Self {
TeamMatchesParser {
selector_title: Selector::parse(SELECTOR_SEASON_TITLE).unwrap(),
selector_matches: Selector::parse(SELECTOR_SEASON_MATCHES).unwrap(),
selector_match: Selector::parse(SELECTOR_SEASON_MATCH).unwrap(),
selector_division: Selector::parse(SELECTOR_SEASON_DIVISION).unwrap(),
selector_week: Selector::parse(SELECTOR_SEASON_WEEK).unwrap(),
selector_date: Selector::parse(SELECTOR_SEASON_DATE).unwrap(),
selector_side: Selector::parse(SELECTOR_SEASON_SIDE).unwrap(),
selector_opponent: Selector::parse(SELECTOR_SEASON_OPPONENT).unwrap(),
selector_map: Selector::parse(SELECTOR_SEASON_MAP).unwrap(),
selector_scores: Selector::parse(SELECTOR_SEASON_SCORES).unwrap(),
selector_points: Selector::parse(SELECTOR_SEASON_POINTS).unwrap(),
selector_points_opponent: Selector::parse(SELECTOR_SEASON_POINTS_OPPONENTS).unwrap(),
}
}
}
impl Parser for TeamMatchesParser {
type Output = Vec<TeamSeason>;
fn parse(&self, document: &str) -> Result<Self::Output> {
let document = Html::parse_document(document);
document
.select(&self.selector_title)
.zip(document.select(&self.selector_matches))
.map(|(title, matches)| {
let title = title.first_text().ok_or(ParseError::EmptyText {
selector: SELECTOR_SEASON_TITLE,
role: "season title",
})?;
let season: u32 = title.trim_start_matches("Season ").parse().map_err(|_| {
ParseError::InvalidText {
text: title.to_string(),
role: "season title",
}
})?;
let matches = matches
.select(&self.selector_match)
.map(|game| {
let division = select_text(game, &self.selector_division).ok_or(
ParseError::ElementNotFound {
selector: SELECTOR_SEASON_DIVISION,
role: "match division",
},
)?;
let week = select_text(game, &self.selector_week).ok_or(
ParseError::ElementNotFound {
selector: SELECTOR_SEASON_WEEK,
role: "match week",
},
)?;
let week = week.parse().map_err(|_| ParseError::InvalidText {
text: week.to_string(),
role: "match week",
})?;
let date = select_text(game, &self.selector_date).ok_or(
ParseError::ElementNotFound {
selector: SELECTOR_SEASON_DATE,
role: "match date",
},
)?;
let side = select_text(game, &self.selector_side).ok_or(
ParseError::ElementNotFound {
selector: SELECTOR_SEASON_SIDE,
role: "match side",
},
)?;
let opponent_link = game.select(&self.selector_opponent).next();
let map = select_text(game, &self.selector_map).ok_or(
ParseError::ElementNotFound {
selector: SELECTOR_SEASON_MAP,
role: "match map",
},
)?;
let scores = select_text(game, &self.selector_scores)
.ok_or(ParseError::ElementNotFound {
selector: SELECTOR_SEASON_SCORES,
role: "match scores",
})?
.trim_start_matches('(')
.trim_end_matches(')');
let points = select_text(game, &self.selector_points);
let points_opponent = select_text(game, &self.selector_points_opponent);
let points = points
.map(|points| {
points.parse().map_err(|_| ParseError::InvalidText {
text: points.to_string(),
role: "match points",
})
})
.transpose()?;
let points_opponent = points_opponent
.map(|points| {
points.parse().map_err(|_| ParseError::InvalidText {
text: points.to_string(),
role: "match points opponent",
})
})
.transpose()?;
let (score, score_opponent) =
scores
.split_once(" -\n")
.ok_or_else(|| ParseError::InvalidText {
text: scores.to_string(),
role: "match scores",
})?;
let score = score.parse().map_err(|_| ParseError::InvalidText {
text: scores.to_string(),
role: "match scores",
});
let score_opponent =
score_opponent.parse().map_err(|_| ParseError::InvalidText {
text: scores.to_string(),
role: "match scores",
});
let opponent = opponent_link
.map(|link| {
let name = link.first_text().ok_or(ParseError::EmptyText {
selector: SELECTOR_SEASON_OPPONENT,
role: "match opponent",
})?;
let id = team_id_from_link(link.attr("href").unwrap_or_default())?;
Result::<_, ParseError>::Ok(TeamRef {
name: name.to_string(),
id,
})
})
.transpose()?;
let result = match (opponent, points, points_opponent) {
(Some(opponent), Some(point), Some(points_opponent)) => {
MatchResult::Played {
opponent,
score: score?,
score_opponent: score_opponent?,
match_points: point,
match_points_opponent: points_opponent,
}
}
(Some(opponent), None, None) => MatchResult::Pending {
opponent,
score: score?,
score_opponent: score_opponent?,
},
_ => MatchResult::ByeWeek,
};
Ok(TeamSeasonMatch {
week,
date: date.to_string(),
side: side.to_string(),
map: map.to_string(),
division: division.to_string(),
result,
})
})
.collect::<Result<_>>()?;
Ok(TeamSeason { season, matches })
})
.collect::<Result<Vec<_>>>()
}
}

View file

@ -0,0 +1,97 @@
use super::Parser;
use crate::data::RosterHistory;
use crate::parser::{select_text, ROSTER_HISTORY_DATE_FORMAT};
use crate::{ParseError, Result};
use scraper::{Html, Selector};
use steamid_ng::SteamID;
use time::Date;
const SELECTOR_ROSTER_ITEM: &str =
".container .white-row-small .row-fluid > .col-md-12 > .clearfix";
const SELECTOR_ROSTER_NAME: &str = "h5 b";
const SELECTOR_ROSTER_ID: &str = "h5 small";
const SELECTOR_ROSTER_JOINED: &str = "span.text-success small";
const SELECTOR_ROSTER_LEFT: &str = "span.text-danger small";
pub struct TeamRosterHistoryParser {
selector_item: Selector,
selector_name: Selector,
selector_id: Selector,
selector_joined: Selector,
selector_left: Selector,
}
impl Default for TeamRosterHistoryParser {
fn default() -> Self {
Self::new()
}
}
impl TeamRosterHistoryParser {
pub fn new() -> Self {
TeamRosterHistoryParser {
selector_item: Selector::parse(SELECTOR_ROSTER_ITEM).unwrap(),
selector_name: Selector::parse(SELECTOR_ROSTER_NAME).unwrap(),
selector_id: Selector::parse(SELECTOR_ROSTER_ID).unwrap(),
selector_joined: Selector::parse(SELECTOR_ROSTER_JOINED).unwrap(),
selector_left: Selector::parse(SELECTOR_ROSTER_LEFT).unwrap(),
}
}
}
impl Parser for TeamRosterHistoryParser {
type Output = Vec<RosterHistory>;
fn parse(&self, document: &str) -> Result<Self::Output> {
let document = Html::parse_document(document);
document
.select(&self.selector_item)
.map(|item| {
let name =
select_text(item, &self.selector_name).ok_or(ParseError::ElementNotFound {
selector: SELECTOR_ROSTER_NAME,
role: "member name",
})?;
let steam_id =
select_text(item, &self.selector_id).ok_or(ParseError::ElementNotFound {
selector: SELECTOR_ROSTER_ID,
role: "member steam id",
})?;
let joined = select_text(item, &self.selector_joined).ok_or(
ParseError::ElementNotFound {
selector: SELECTOR_ROSTER_JOINED,
role: "member joined date",
},
)?;
let left = select_text(item, &self.selector_left);
Ok(RosterHistory {
name: name.to_string(),
steam_id: SteamID::from_steam3(steam_id).map_err(|_| {
ParseError::InvalidText {
text: steam_id.to_string(),
role: "member steam id",
}
})?,
joined: Date::parse(joined, ROSTER_HISTORY_DATE_FORMAT).map_err(|_| {
ParseError::InvalidDate {
date: steam_id.to_string(),
role: "member join date",
}
})?,
left: left
.map(|left| {
Date::parse(left, ROSTER_HISTORY_DATE_FORMAT).map_err(|_| {
ParseError::InvalidDate {
date: steam_id.to_string(),
role: "member join date",
}
})
})
.transpose()?,
})
})
.collect::<Result<Vec<_>>>()
}
}

3297
tests/data/team_6929.html Normal file

File diff suppressed because it is too large Load diff

3327
tests/data/team_7861.html Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,19 +0,0 @@
use insta::assert_json_snapshot;
use std::fs::read_to_string;
use ugc_scraper::parser::{Parser, PlayerDetailsParser, PlayerParser};
#[test]
fn test_parse_player_html() {
let body = read_to_string("tests/data/player_76561198024494988.html").unwrap();
let parser = PlayerParser::new();
let parsed = parser.parse(&body).unwrap();
assert_json_snapshot!(parsed);
}
#[test]
fn test_parse_player_details_html() {
let body = read_to_string("tests/data/player_details_76561198024494988.html").unwrap();
let parser = PlayerDetailsParser::new();
let parsed = parser.parse(&body).unwrap();
assert_json_snapshot!(parsed);
}

54
tests/snapshot.rs Normal file
View file

@ -0,0 +1,54 @@
use insta::assert_json_snapshot;
use std::fs::read_to_string;
use ugc_scraper::parser::{
Parser, PlayerDetailsParser, PlayerParser, TeamMatchesParser, TeamParser,
TeamRosterHistoryParser,
};
#[test]
fn test_parse_player_html() {
let body = read_to_string("tests/data/player_76561198024494988.html").unwrap();
let parser = PlayerParser::new();
let parsed = parser.parse(&body).unwrap();
assert_json_snapshot!(parsed);
}
#[test]
fn test_parse_player_details_html() {
let body = read_to_string("tests/data/player_details_76561198024494988.html").unwrap();
let parser = PlayerDetailsParser::new();
let parsed = parser.parse(&body).unwrap();
assert_json_snapshot!(parsed);
}
#[test]
fn test_parse_team_html() {
let body = read_to_string("tests/data/team_7861.html").unwrap();
let parser = TeamParser::new();
let parsed = parser.parse(&body).unwrap();
assert_json_snapshot!(parsed);
}
#[test]
fn test_parse_team_changed_name_html() {
let body = read_to_string("tests/data/team_6929.html").unwrap();
let parser = TeamParser::new();
let parsed = parser.parse(&body).unwrap();
assert_json_snapshot!(parsed);
}
#[test]
fn test_parse_team_roster_history_html() {
let body = read_to_string("tests/data/team_roster_history_7861.html").unwrap();
let parser = TeamRosterHistoryParser::new();
let parsed = parser.parse(&body).unwrap();
assert_json_snapshot!(parsed);
}
#[test]
fn test_parse_team_matches_html() {
let body = read_to_string("tests/data/team_matches_7861.html").unwrap();
let parser = TeamMatchesParser::new();
let parsed = parser.parse(&body).unwrap();
assert_json_snapshot!(parsed);
}

View file

@ -1,5 +1,5 @@
---
source: tests/player.rs
source: tests/snapshot.rs
expression: parsed
---
[

View file

@ -1,5 +1,5 @@
---
source: tests/player.rs
source: tests/snapshot.rs
expression: parsed
---
{

View file

@ -0,0 +1,583 @@
---
source: tests/snapshot.rs
expression: parsed
---
{
"name": "UGC 6s",
"tag": "Europe",
"image": "https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/09/096a30b1025c586f9d41c686077129f6e86998d0_full.jpg",
"format": "TF2 6vs6",
"timezone": "West-Euro",
"division": "Europe",
"description": "Giel is Jesus",
"titles": [
"TF2 6v6 S38 Europe 3rd Place",
"TF2 6v6 EU Steel Champions Season 14"
],
"members": [
{
"name": "GCKimo",
"steam_id": 76561197992327511,
"role": "Leader",
"since": [
2013,
127,
9,
31,
0,
0,
-5,
0,
0
]
},
{
"name": "Gielewiel9",
"steam_id": 76561198061174419,
"role": "Member",
"since": [
2013,
254,
1,
21,
0,
0,
-5,
0,
0
]
},
{
"name": "SUZY Sacrénom d'un",
"steam_id": 76561198004734774,
"role": "Member",
"since": [
2013,
276,
1,
45,
0,
0,
-5,
0,
0
]
},
{
"name": "Vipe",
"steam_id": 76561198059011634,
"role": "Member",
"since": [
2014,
141,
3,
32,
0,
0,
-5,
0,
0
]
},
{
"name": "spreijer tf2lt",
"steam_id": 76561198032234067,
"role": "Member",
"since": [
2014,
155,
1,
6,
0,
0,
-5,
0,
0
]
},
{
"name": "Herpa",
"steam_id": 76561198183437643,
"role": "Member",
"since": [
2016,
173,
3,
5,
0,
0,
-5,
0,
0
]
},
{
"name": "Icewind demostf",
"steam_id": 76561198024494988,
"role": "Member",
"since": [
2017,
52,
3,
52,
0,
0,
-5,
0,
0
]
},
{
"name": "Vclox",
"steam_id": 76561198056783619,
"role": "Member",
"since": [
2018,
59,
2,
29,
0,
0,
-5,
0,
0
]
},
{
"name": "Fish",
"steam_id": 76561198052362074,
"role": "Member",
"since": [
2018,
171,
2,
12,
0,
0,
-5,
0,
0
]
},
{
"name": "Bobbert",
"steam_id": 76561198071877015,
"role": "Member",
"since": [
2019,
170,
1,
59,
0,
0,
-5,
0,
0
]
},
{
"name": "Kaga",
"steam_id": 76561198040965137,
"role": "Member",
"since": [
2020,
42,
6,
35,
0,
0,
-5,
0,
0
]
},
{
"name": "GMsU CreepsiliusM",
"steam_id": 76561198071903356,
"role": "Member",
"since": [
2022,
284,
4,
43,
0,
0,
-5,
0,
0
]
},
{
"name": "DelT",
"steam_id": 76561198204007537,
"role": "Member",
"since": [
2023,
60,
3,
2,
0,
0,
-5,
0,
0
]
},
{
"name": "Deity",
"steam_id": 76561198076020012,
"role": "Member",
"since": [
2023,
228,
4,
31,
0,
0,
-5,
0,
0
]
},
{
"name": "Ikaros",
"steam_id": 76561198158482651,
"role": "Member",
"since": [
2023,
228,
4,
32,
0,
0,
-5,
0,
0
]
}
],
"results": [
{
"season": 42,
"division": "Europe",
"wins": 3,
"losses": 5
},
{
"season": 40,
"division": "Europe",
"wins": 4,
"losses": 4
},
{
"season": 39,
"division": "Europe",
"wins": 3,
"losses": 5
},
{
"season": 38,
"division": "Europe",
"wins": 5,
"losses": 6
},
{
"season": 37,
"division": "Europe",
"wins": 4,
"losses": 4
},
{
"season": 36,
"division": "Europe",
"wins": 3,
"losses": 5
},
{
"season": 35,
"division": "Europe",
"wins": 5,
"losses": 4
},
{
"season": 34,
"division": "Europe",
"wins": 6,
"losses": 3
},
{
"season": 33,
"division": "Europe",
"wins": 3,
"losses": 5
},
{
"season": 32,
"division": "Europe",
"wins": 5,
"losses": 5
},
{
"season": 31,
"division": "Europe",
"wins": 7,
"losses": 3
},
{
"season": 30,
"division": "Europe",
"wins": 3,
"losses": 6
},
{
"season": 29,
"division": "Europe",
"wins": 4,
"losses": 3
},
{
"season": 28,
"division": "Europe",
"wins": 3,
"losses": 5
},
{
"season": 27,
"division": "Europe",
"wins": 5,
"losses": 4
},
{
"season": 26,
"division": "Europe",
"wins": 4,
"losses": 4
},
{
"season": 25,
"division": "Europe",
"wins": 3,
"losses": 5
},
{
"season": 24,
"division": "Europe",
"wins": 4,
"losses": 6
},
{
"season": 23,
"division": "Europe",
"wins": 4,
"losses": 5
},
{
"season": 22,
"division": "Euro Platinum",
"wins": 2,
"losses": 3
},
{
"season": 21,
"division": "Euro Gold",
"wins": 3,
"losses": 5
},
{
"season": 20,
"division": "Euro Steel",
"wins": 4,
"losses": 5
},
{
"season": 19,
"division": "Euro Steel",
"wins": 5,
"losses": 2
},
{
"season": 18,
"division": "Euro Steel",
"wins": 2,
"losses": 5
},
{
"season": 17,
"division": "Euro Steel",
"wins": 5,
"losses": 4
},
{
"season": 16,
"division": "Euro Platinum",
"wins": 4,
"losses": 6
},
{
"season": 15,
"division": "Euro Platinum",
"wins": 3,
"losses": 3
},
{
"season": 14,
"division": "Euro Steel",
"wins": 11,
"losses": 1
},
{
"season": 13,
"division": "Euro Platinum",
"wins": 3,
"losses": 7
},
{
"season": 12,
"division": "Euro Steel",
"wins": 5,
"losses": 3
}
],
"name_changes": [
{
"from_tag": "by Kimo",
"from": "Xenon 2",
"to_tag": "Europe",
"to": "UGC 6s",
"date": [
2023,
60
]
},
{
"from_tag": "John 2",
"from": "Let's Claim the Default",
"to_tag": "by Kimo",
"to": "Xenon 2",
"date": [
2022,
278
]
},
{
"from_tag": "John 2",
"from": "https://youtu.be/iio-P3ubZtE",
"to_tag": "John 2",
"to": "Let's Claim the Default",
"date": [
2022,
170
]
},
{
"from_tag": "... I guess?",
"from": "Let's Claim the Default",
"to_tag": "John 2",
"to": "https://youtu.be/iio-P3ubZtE",
"date": [
2022,
137
]
},
{
"from_tag": "360",
"from": "Controller Gamers",
"to_tag": "... I guess?",
"to": "Let's Claim the Default",
"date": [
2021,
181
]
},
{
"from_tag": "bye",
"from": "Bye week",
"to_tag": "360",
"to": "Controller Gamers",
"date": [
2021,
55
]
},
{
"from_tag": "| meta.tf",
"from": "meta.tf",
"to_tag": "bye",
"to": "Bye week",
"date": [
2020,
281
]
},
{
"from_tag": "Giele!",
"from": "Giel and the 9wiels",
"to_tag": "| meta.tf",
"to": "meta.tf",
"date": [
2017,
151
]
},
{
"from_tag": "pFp",
"from": "Popping for points",
"to_tag": "Giele!",
"to": "Giel and the 9wiels",
"date": [
2016,
167
]
},
{
"from_tag": "HLL",
"from": "HL Lite",
"to_tag": "pFp",
"to": "Popping for points",
"date": [
2016,
20
]
},
{
"from_tag": "-AA-",
"from": "Anti-Anime",
"to_tag": "HLL",
"to": "HL Lite",
"date": [
2015,
280
]
},
{
"from_tag": "Frequent",
"from": "Frequently Missing",
"to_tag": "-AA-",
"to": "Anti-Anime",
"date": [
2015,
138
]
},
{
"from_tag": "pinky|",
"from": "Frequently Missing",
"to_tag": "Frequent",
"to": "Frequently Missing",
"date": [
2015,
17
]
},
{
"from_tag": "pinky|",
"from": "Pinky",
"to_tag": "pinky|",
"to": "Frequently Missing",
"date": [
2015,
17
]
}
]
}

View file

@ -0,0 +1,531 @@
---
source: tests/snapshot.rs
expression: parsed
---
{
"name": "Xenon",
"tag": "-Xe-",
"image": "https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/db/dbabbd8bab7ccf6d27a9d4ca2e73a76e085bb201_full.jpg",
"format": "TF2 Highlander",
"timezone": "West-Euro",
"division": "Euro Platinum",
"description": "Contact Kimo or Icewind for everything.",
"titles": [
"TF2 HL EU Silver 3rd Place S18"
],
"members": [
{
"name": "Icewind demostf",
"steam_id": 76561198024494988,
"role": "Leader",
"since": [
2013,
221,
3,
23,
0,
0,
-5,
0,
0
]
},
{
"name": "Fish",
"steam_id": 76561198052362074,
"role": "Leader",
"since": [
2014,
120,
10,
37,
0,
0,
-5,
0,
0
]
},
{
"name": "GCKimo",
"steam_id": 76561197992327511,
"role": "Leader",
"since": [
2016,
171,
11,
24,
0,
0,
-5,
0,
0
]
},
{
"name": "NoSocks",
"steam_id": 76561198012110404,
"role": "Member",
"since": [
2013,
218,
5,
21,
0,
0,
-5,
0,
0
]
},
{
"name": "Shoosh",
"steam_id": 76561198049593717,
"role": "Member",
"since": [
2014,
255,
9,
37,
0,
0,
-5,
0,
0
]
},
{
"name": "Dirty Sneeds Done",
"steam_id": 76561198049312442,
"role": "Member",
"since": [
2015,
266,
12,
24,
0,
0,
-5,
0,
0
]
},
{
"name": "Deity",
"steam_id": 76561198076020012,
"role": "Member",
"since": [
2015,
363,
2,
52,
0,
0,
-5,
0,
0
]
},
{
"name": "jojo",
"steam_id": 76561197995029224,
"role": "Member",
"since": [
2016,
17,
10,
47,
0,
0,
-5,
0,
0
]
},
{
"name": "bigdog",
"steam_id": 76561198076014163,
"role": "Member",
"since": [
2016,
146,
4,
8,
0,
0,
-5,
0,
0
]
},
{
"name": "musTard",
"steam_id": 76561197990486664,
"role": "Member",
"since": [
2017,
17,
7,
43,
0,
0,
-5,
0,
0
]
},
{
"name": "Kaga",
"steam_id": 76561198040965137,
"role": "Member",
"since": [
2018,
312,
6,
42,
0,
0,
-5,
0,
0
]
},
{
"name": "STEEEEEEEEEEELAZ",
"steam_id": 76561198036824480,
"role": "Member",
"since": [
2019,
272,
8,
35,
0,
0,
-5,
0,
0
]
},
{
"name": "Derakusa",
"steam_id": 76561198011495003,
"role": "Member",
"since": [
2020,
275,
4,
28,
0,
0,
-5,
0,
0
]
},
{
"name": "Kireek",
"steam_id": 76561198052694464,
"role": "Member",
"since": [
2022,
27,
4,
17,
0,
0,
-5,
0,
0
]
},
{
"name": "Royal Flush",
"steam_id": 76561198052084714,
"role": "Member",
"since": [
2022,
128,
6,
42,
0,
0,
-5,
0,
0
]
},
{
"name": "BaaBo",
"steam_id": 76561198004331478,
"role": "Member",
"since": [
2023,
9,
10,
18,
0,
0,
-5,
0,
0
]
},
{
"name": "drew",
"steam_id": 76561198012304706,
"role": "Member",
"since": [
2023,
43,
5,
54,
0,
0,
-5,
0,
0
]
},
{
"name": "Raipe",
"steam_id": 76561198061082936,
"role": "Member",
"since": [
2023,
78,
5,
27,
0,
0,
-5,
0,
0
]
},
{
"name": "Teroantero2007",
"steam_id": 76561197996902035,
"role": "Member",
"since": [
2023,
177,
2,
1,
0,
0,
-5,
0,
0
]
},
{
"name": "taskmast33r",
"steam_id": 76561198218881647,
"role": "Member",
"since": [
2023,
203,
4,
46,
0,
0,
-5,
0,
0
]
},
{
"name": "marko",
"steam_id": 76561198274165935,
"role": "Member",
"since": [
2023,
292,
1,
13,
0,
0,
-5,
0,
0
]
}
],
"results": [
{
"season": 40,
"division": "Euro Platinum",
"wins": 3,
"losses": 5
},
{
"season": 39,
"division": "Euro Platinum",
"wins": 4,
"losses": 5
},
{
"season": 38,
"division": "Euro Silver",
"wins": 4,
"losses": 5
},
{
"season": 37,
"division": "Euro Silver",
"wins": 9,
"losses": 2
},
{
"season": 36,
"division": "Euro Platinum",
"wins": 2,
"losses": 6
},
{
"season": 35,
"division": "Euro Platinum",
"wins": 2,
"losses": 6
},
{
"season": 34,
"division": "Euro Platinum",
"wins": 3,
"losses": 6
},
{
"season": 32,
"division": "Euro Platinum",
"wins": 3,
"losses": 5
},
{
"season": 31,
"division": "Euro Platinum",
"wins": 4,
"losses": 4
},
{
"season": 30,
"division": "Euro Platinum",
"wins": 3,
"losses": 5
},
{
"season": 29,
"division": "Euro Platinum",
"wins": 4,
"losses": 4
},
{
"season": 28,
"division": "Euro Platinum",
"wins": 3,
"losses": 3
},
{
"season": 27,
"division": "Premium EU",
"wins": 4,
"losses": 5
},
{
"season": 26,
"division": "Premium EU",
"wins": 3,
"losses": 5
},
{
"season": 25,
"division": "Euro Platinum",
"wins": 5,
"losses": 6
},
{
"season": 24,
"division": "Euro Platinum",
"wins": 4,
"losses": 4
},
{
"season": 23,
"division": "Euro Platinum",
"wins": 5,
"losses": 6
},
{
"season": 22,
"division": "Euro Platinum",
"wins": 4,
"losses": 6
},
{
"season": 21,
"division": "Euro Platinum",
"wins": 4,
"losses": 4
},
{
"season": 20,
"division": "Euro Platinum",
"wins": 2,
"losses": 5
},
{
"season": 19,
"division": "Euro Gold",
"wins": 6,
"losses": 5
},
{
"season": 18,
"division": "Euro Silver",
"wins": 7,
"losses": 4
},
{
"season": 17,
"division": "Euro Silver",
"wins": 3,
"losses": 5
},
{
"season": 16,
"division": "Euro Silver",
"wins": 3,
"losses": 5
},
{
"season": 15,
"division": "Euro Silver",
"wins": 2,
"losses": 6
},
{
"season": 14,
"division": "Euro Silver",
"wins": 3,
"losses": 5
},
{
"season": 13,
"division": "Euro Silver",
"wins": 3,
"losses": 6
},
{
"season": 12,
"division": "Euro Silver",
"wins": 4,
"losses": 5
},
{
"season": 11,
"division": "Euro Steel",
"wins": 4,
"losses": 4
}
],
"name_changes": []
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff