From 89f58c704a260c46bdd2a581521c4088e6fa4045 Mon Sep 17 00:00:00 2001 From: Vansh Bordia Date: Thu, 20 Nov 2025 20:30:13 +0530 Subject: [PATCH 1/3] fix: updated series modules fo teamids --- src/vlrdevapi/series/info.py | 6 ++++ src/vlrdevapi/series/matches.py | 63 +++++++++++++++++++++++++++++---- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/vlrdevapi/series/info.py b/src/vlrdevapi/series/info.py index 18aeae2..a2bce4a 100644 --- a/src/vlrdevapi/series/info.py +++ b/src/vlrdevapi/series/info.py @@ -123,6 +123,12 @@ def info(match_id: int, timeout: float | None = None) -> Info | None: if t2_id: t2_short, t2_country, t2_country_code = team_meta_map.get(t2_id, (None, None, None)) + # Use team name as short name fallback for teams without a short tag (e.g., MIBR) + if not t1_short and t1: + t1_short = t1 + if not t2_short and t2: + t2_short = t2 + s1 = header.select_one(".match-header-vs-score-winner") s2 = header.select_one(".match-header-vs-score-loser") raw_score: tuple[int | None, int | None] = (None, None) diff --git a/src/vlrdevapi/series/matches.py b/src/vlrdevapi/series/matches.py index e0b8b10..9c65f83 100644 --- a/src/vlrdevapi/series/matches.py +++ b/src/vlrdevapi/series/matches.py @@ -72,19 +72,62 @@ def canonical(value: str | None) -> str | None: return None return _WHITESPACE_RE.sub(" ", value).strip().lower() + # Extract team IDs from the page header for direct lookup + page_team_ids: list[int | None] = [None, None] + match_header = soup.select_one(".wf-card.match-header") + if match_header: + t1_link = match_header.select_one(".match-header-link.mod-1") + t2_link = match_header.select_one(".match-header-link.mod-2") + if t1_link: + href_val = t1_link.get("href") + href = href_val if isinstance(href_val, str) else None + page_team_ids[0] = extract_id_from_url(href, "team") + if t2_link: + href_val = t2_link.get("href") + href = href_val if isinstance(href_val, str) else None + page_team_ids[1] = extract_id_from_url(href, "team") + # Fetch team metadata to map names/shorts to IDs series_details = info(series_id, timeout=timeout) team_meta_lookup: dict[str, dict[str, str | int | None]] = {} team_short_to_id: dict[str, int | None] = {} + team_id_to_meta: dict[int, dict[str, str | int | None]] = {} + if series_details: for team_info in series_details.teams: team_meta_rec: dict[str, str | int | None] = {"id": team_info.id, "name": team_info.name, "short": team_info.short} - for key in filter(None, [team_info.name, team_info.short]): - canon = canonical(key) - if canon is not None: - team_meta_lookup[canon] = team_meta_rec + + # Store by team ID for direct lookup + if team_info.id: + team_id_to_meta[team_info.id] = team_meta_rec + + # Build list of all possible name variations + name_variations: list[str] = [] + + # Add the full name + if team_info.name: + name_variations.append(team_info.name) + + # Extract name from parentheses if present (e.g., "Guangzhou Huadu Bilibili Gaming(Bilibili Gaming)" -> "Bilibili Gaming") + if "(" in team_info.name and ")" in team_info.name: + start = team_info.name.rfind("(") + end = team_info.name.rfind(")") + if start < end: + paren_name = team_info.name[start + 1:end].strip() + if paren_name: + name_variations.append(paren_name) + + # Add the short name if team_info.short: + name_variations.append(team_info.short) team_short_to_id[team_info.short.upper()] = team_info.id + + # Index all variations + for variation in name_variations: + canon = canonical(variation) + if canon: + team_meta_lookup[canon] = team_meta_rec + # Determine order from nav ordered_ids: list[str] = [] @@ -199,9 +242,16 @@ def canonical(value: str | None) -> str | None: t1_meta = team_meta_lookup.get(c1) if c1 else None t2_meta = team_meta_lookup.get(c2) if c2 else None - t1_id_val = t1_meta.get("id") if t1_meta else None + # If name lookup failed, try using page team IDs + if not t1_meta and page_team_ids[0]: + t1_meta = team_id_to_meta.get(page_team_ids[0]) + if not t2_meta and page_team_ids[1]: + t2_meta = team_id_to_meta.get(page_team_ids[1]) + + # Extract ID and short from metadata, with page team IDs as fallback + t1_id_val = t1_meta.get("id") if t1_meta else page_team_ids[0] t1_short_val = t1_meta.get("short") if t1_meta else None - t2_id_val = t2_meta.get("id") if t2_meta else None + t2_id_val = t2_meta.get("id") if t2_meta else page_team_ids[1] t2_short_val = t2_meta.get("short") if t2_meta else None teams_tuple = ( @@ -224,6 +274,7 @@ def canonical(value: str | None) -> str | None: is_winner=t2_is_winner, ), ) + # Parse rounds rounds_list: list[RoundResult] = [] From 15d11bd9ce6b1e19b0d24933ee3b1e2ec0cc10a4 Mon Sep 17 00:00:00 2001 From: Vansh Bordia Date: Tue, 25 Nov 2025 21:22:24 +0530 Subject: [PATCH 2/3] feat:Series Modules add --- docs/source/api/series.rst | 59 ++- src/vlrdevapi/series/__init__.py | 7 +- src/vlrdevapi/series/models.py | 75 +++ src/vlrdevapi/series/performance.py | 709 ++++++++++++++++++++++++++++ tests/lib/test_series.py | 71 +++ 5 files changed, 918 insertions(+), 3 deletions(-) create mode 100644 src/vlrdevapi/series/performance.py diff --git a/docs/source/api/series.rst b/docs/source/api/series.rst index b154855..d23a03c 100644 --- a/docs/source/api/series.rst +++ b/docs/source/api/series.rst @@ -28,6 +28,12 @@ matches .. autofunction:: vlrdevapi.series.matches :noindex: +performance +~~~~~~~~~~~ + +.. autofunction:: vlrdevapi.series.performance + :noindex: + Data Models ----------- @@ -80,6 +86,27 @@ MapPlayers :members: :undoc-members: +MultiKillDetail +~~~~~~~~~~~~~~~ + +.. autoclass:: vlrdevapi.series.MultiKillDetail + :members: + :undoc-members: + +ClutchDetail +~~~~~~~~~~~~ + +.. autoclass:: vlrdevapi.series.ClutchDetail + :members: + :undoc-members: + +PlayerPerformance +~~~~~~~~~~~~~~~~~ + +.. autoclass:: vlrdevapi.series.PlayerPerformance + :members: + :undoc-members: + Usage Examples -------------- @@ -171,7 +198,7 @@ Top Performers import vlrdevapi as vlr maps = vlr.series.matches(series_id=530935) - + for map_data in maps: # Sort by ACS sorted_players = sorted( @@ -179,9 +206,37 @@ Top Performers key=lambda p: p.acs or 0, reverse=True ) - + print(f"\n{map_data.map_name} - Top 3 Performers:") for player in sorted_players[:3]: print(f" {player.name}: {player.acs} ACS, {player.k}/{player.d}/{player.a}") +Match Performance Statistics +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + import vlrdevapi as vlr + + # Get performance statistics for a series + perf = vlr.series.performance(series_id=542210) + + for game in perf: + print(f"\nMap: {game.map_name}") + for player in game.player_performances: + print(f" {player.name} ({player.team_short})") + print(f" 2Ks: {player.multi_2k}, 3Ks: {player.multi_3k}, 4Ks: {player.multi_4k}, 5Ks: {player.multi_5k}") + print(f" 1v1: {player.clutch_1v1}, 1v2: {player.clutch_1v2}, 1v3: {player.clutch_1v3}") + print(f" ECON: {player.econ}, Plants: {player.plants}, Defuses: {player.defuses}") + + # Detailed performance information + if player.multi_2k_details: + print(f" 2K Details: {len(player.multi_2k_details)} events") + for detail in player.multi_2k_details: + print(f" Round {detail.round_number}: {', '.join(detail.players_killed)}") + if player.clutch_1v2_details: + print(f" 1v2 Details: {len(player.clutch_1v2_details)} events") + for detail in player.clutch_1v2_details: + print(f" Round {detail.round_number}: {', '.join(detail.players_killed)}") + See more examples: :doc:`../examples` diff --git a/src/vlrdevapi/series/__init__.py b/src/vlrdevapi/series/__init__.py index a9c0307..3a6038d 100644 --- a/src/vlrdevapi/series/__init__.py +++ b/src/vlrdevapi/series/__init__.py @@ -1,8 +1,9 @@ """Series/match-related API endpoints and models.""" -from .models import TeamInfo, MapAction, Info, PlayerStats, MapTeamScore, RoundResult, MapPlayers +from .models import TeamInfo, MapAction, Info, PlayerStats, MapTeamScore, RoundResult, MapPlayers, KillMatrixEntry, PlayerPerformance, MapPerformance from .info import info from .matches import matches +from .performance import performance __all__ = [ # Models @@ -13,7 +14,11 @@ "MapTeamScore", "RoundResult", "MapPlayers", + "KillMatrixEntry", + "PlayerPerformance", + "MapPerformance", # Functions "info", "matches", + "performance", ] diff --git a/src/vlrdevapi/series/models.py b/src/vlrdevapi/series/models.py index c3c449f..b6fe941 100644 --- a/src/vlrdevapi/series/models.py +++ b/src/vlrdevapi/series/models.py @@ -106,3 +106,78 @@ class MapPlayers: players: list["PlayerStats"] = field(default_factory=list) teams: tuple["MapTeamScore", "MapTeamScore"] | None = None rounds: list["RoundResult"] | None = None + + +@dataclass(frozen=True) +class KillMatrixEntry: + """Single entry in a kill matrix showing kills between two players.""" + + killer_name: str + victim_name: str + killer_team_short: str | None = None + killer_team_id: int | None = None + victim_team_short: str | None = None + victim_team_id: int | None = None + kills: int | None = None + deaths: int | None = None + differential: int | None = None + + +@dataclass(frozen=True) +class MultiKillDetail: + """Detailed information about a multi-kill event.""" + + round_number: int + players_killed: list[str] + + +@dataclass(frozen=True) +class ClutchDetail: + """Detailed information about a clutch event.""" + + round_number: int + players_killed: list[str] + + +@dataclass(frozen=True) +class PlayerPerformance: + """Player performance statistics including multi-kills, clutches, and economy.""" + + name: str + team_short: str | None = None + team_id: int | None = None + agent: str | None = None + multi_2k: int | None = None + multi_3k: int | None = None + multi_4k: int | None = None + multi_5k: int | None = None + clutch_1v1: int | None = None + clutch_1v2: int | None = None + clutch_1v3: int | None = None + clutch_1v4: int | None = None + clutch_1v5: int | None = None + econ: int | None = None + plants: int | None = None + defuses: int | None = None + # Detailed information for multi-kills and clutches + multi_2k_details: list[MultiKillDetail] | None = None + multi_3k_details: list[MultiKillDetail] | None = None + multi_4k_details: list[MultiKillDetail] | None = None + multi_5k_details: list[MultiKillDetail] | None = None + clutch_1v1_details: list[ClutchDetail] | None = None + clutch_1v2_details: list[ClutchDetail] | None = None + clutch_1v3_details: list[ClutchDetail] | None = None + clutch_1v4_details: list[ClutchDetail] | None = None + clutch_1v5_details: list[ClutchDetail] | None = None + + +@dataclass(frozen=True) +class MapPerformance: + """Performance statistics for a single map/game.""" + + game_id: int | str | None = None + map_name: str | None = None + kill_matrix: list["KillMatrixEntry"] = field(default_factory=list) + fkfd_matrix: list["KillMatrixEntry"] = field(default_factory=list) + op_matrix: list["KillMatrixEntry"] = field(default_factory=list) + player_performances: list["PlayerPerformance"] = field(default_factory=list) diff --git a/src/vlrdevapi/series/performance.py b/src/vlrdevapi/series/performance.py new file mode 100644 index 0000000..3da4039 --- /dev/null +++ b/src/vlrdevapi/series/performance.py @@ -0,0 +1,709 @@ +"""Series performance functionality.""" + +from __future__ import annotations + +from bs4 import BeautifulSoup +from bs4.element import Tag + +from .models import MapPerformance, KillMatrixEntry, PlayerPerformance +from ._parser import _WHITESPACE_RE, _MAP_NUMBER_RE +from ..config import get_config +from ..fetcher import fetch_html +from ..exceptions import NetworkError +from ..utils import extract_text, parse_int + +_config = get_config() + + +def performance(series_id: int, limit: int | None = None, timeout: float | None = None) -> list[MapPerformance]: + """ + Get performance statistics for a series. + + Args: + series_id: Series/match ID + limit: Maximum number of maps to return (optional) + timeout: Request timeout in seconds + + Returns: + List of map performance statistics + + Example: + >>> import vlrdevapi as vlr + >>> perf = vlr.series.performance(series_id=542210) + >>> for game in perf: + ... print(f"Game: {game.map_name}") + ... for player in game.player_performances: + ... print(f" {player.name}: {player.multi_2k} 2Ks, {player.econ} ECON") + """ + url = f"{_config.vlr_base}/{series_id}?game=all&tab=performance" + effective_timeout = timeout if timeout is not None else _config.default_timeout + try: + html = fetch_html(url, effective_timeout) + except NetworkError: + return [] + + soup = BeautifulSoup(html, "lxml") + stats_root = soup.select_one(".vm-stats") + if not stats_root: + return [] + + # Extract team IDs from page header (similar to matches module) + page_team_ids: list[int | None] = [None, None] + page = soup # Full page soup + match_header = page.select_one(".wf-card.match-header") + if match_header: + from ..utils import extract_id_from_url + t1_link = match_header.select_one(".match-header-link.mod-1") + t2_link = match_header.select_one(".match-header-link.mod-2") + if t1_link: + href_val = t1_link.get("href") + href = href_val if isinstance(href_val, str) else None + page_team_ids[0] = extract_id_from_url(href, "team") + if t2_link: + href_val = t2_link.get("href") + href = href_val if isinstance(href_val, str) else None + page_team_ids[1] = extract_id_from_url(href, "team") + + # Get team tags from page to map to IDs + team_tag_to_id: dict[str, int | None] = {} + if match_header: + # Get team names from the .wf-title-med elements + team_title_els = match_header.select(".wf-title-med") + if len(team_title_els) >= 2: + team1_name = extract_text(team_title_els[0]) + team2_name = extract_text(team_title_els[1]) + if team1_name: + team1_upper = team1_name.strip().upper() + if page_team_ids[0]: + team_tag_to_id[team1_upper] = page_team_ids[0] + + # Handle common abbreviations + if team1_upper == "TEAM LIQUID": + team_tag_to_id["TL"] = page_team_ids[0] + elif team1_upper == "DRX": + team_tag_to_id["DRX"] = page_team_ids[0] # DRX is both full name and abbreviation + + if team2_name: + team2_upper = team2_name.strip().upper() + if page_team_ids[1]: + team_tag_to_id[team2_upper] = page_team_ids[1] + + # Handle common abbreviations + if team2_upper == "TEAM LIQUID": + team_tag_to_id["TL"] = page_team_ids[1] + elif team2_upper == "DRX": + team_tag_to_id["DRX"] = page_team_ids[1] # DRX is both full name and abbreviation + + # If team IDs weren't found from the header, fetch the main match page to get team IDs + if not all(id is not None for id in page_team_ids): + # Try to get the main match page (overview tab) to extract team IDs + overview_url = f"{_config.vlr_base}/{series_id}" + try: + overview_html = fetch_html(overview_url, effective_timeout) + overview_soup = BeautifulSoup(overview_html, "lxml") + overview_match_header = overview_soup.select_one(".wf-card.match-header") + if overview_match_header: + t1_link = overview_match_header.select_one(".match-header-link.mod-1") + t2_link = overview_match_header.select_one(".match-header-link.mod-2") + if t1_link and page_team_ids[0] is None: + href_val = t1_link.get("href") + href = href_val if isinstance(href_val, str) else None + page_team_ids[0] = extract_id_from_url(href, "team") + if t2_link and page_team_ids[1] is None: + href_val = t2_link.get("href") + href = href_val if isinstance(href_val, str) else None + page_team_ids[1] = extract_id_from_url(href, "team") + + # Also extract team names from the overview page + team_title_els = overview_match_header.select(".wf-title-med") + if len(team_title_els) >= 2: + team1_name = extract_text(team_title_els[0]) + team2_name = extract_text(team_title_els[1]) + if team1_name: + team1_upper = team1_name.strip().upper() + if page_team_ids[0]: + team_tag_to_id[team1_upper] = page_team_ids[0] + + # Handle common abbreviations + if team1_upper == "TEAM LIQUID": + team_tag_to_id["TL"] = page_team_ids[0] + elif team1_upper == "DRX": + team_tag_to_id["DRX"] = page_team_ids[0] # DRX is both full name and abbreviation + + if team2_name: + team2_upper = team2_name.strip().upper() + if page_team_ids[1]: + team_tag_to_id[team2_upper] = page_team_ids[1] + + # Handle common abbreviations + if team2_upper == "TEAM LIQUID": + team_tag_to_id["TL"] = page_team_ids[1] + elif team2_upper == "DRX": + team_tag_to_id["DRX"] = page_team_ids[1] # DRX is both full name and abbreviation + except NetworkError: + # If we can't fetch the overview page, continue with what we have + pass + + # Add additional common team abbreviations that might be used in performance tables + # Create reverse mapping based on known full team names in the dictionary + common_abbreviations = { + "TL": "TEAM LIQUID", + "DRX": "DRX", + "FNC": "FNC ESPORTS", + "G2": "G2", + "ENCE": "ENCE", + "LOUD": "LOUD", + "PRX": "PAPER REX", + "T1": "T1", + "GEN": "GEN.G", + "LEV": "LEVIATAN", + "KRÜ": "KRÜ ESPORTS", + "KPR": "KATARU", + "ZETA": "ZETA DIVISION", + "TF": "TWO ALPHA", + "FUT": "FUTURO", + "KC": "KEYD STARS", + "INF": "INFINITE ESPORTS", + "GX": "GIANTX", + "NRG": "NRG", + "SEN": "SENPAI ESPORTS", + "ACN": "AVANTARIA CINCO", + "KBM": "KBM PLUG'N PLAY", + "FUR": "FURIA ESPORTS", + "BAD": "BAD NEWS EAGLES", + "C9": "CLOUD9", + "EG": "EVIL GENIUSES", + "OPTC": "OPTC", + "TSM": "TSM", + "100T": "100 THIEVES", + "FNC": "FNC ESPORTS", + "BBL": "BIGBEN LOUNGE", + "GIA": "GIANTX", + "RENA": "RENASCENCE", + "VIT": "VITALITY", + "GL": "GOLDEN GORILLAS", + } + + # Map abbreviations to IDs based on what we already know + for abbrev, full_name in common_abbreviations.items(): + full_upper = full_name.upper() + if full_upper in team_tag_to_id and abbrev not in team_tag_to_id: + team_tag_to_id[abbrev] = team_tag_to_id[full_upper] + + # Build game_id -> map name from tabs (including "All") + game_name_map: dict[int | str, str] = {} + for nav in stats_root.select("[data-game-id]"): + classes_val = nav.get("class") + nav_classes: list[str] = [str(c) for c in classes_val] if isinstance(classes_val, (list, tuple)) else [] + # Skip the actual game sections (with exact class "vm-stats-game"), not the nav items + if "vm-stats-game" in nav_classes: + continue + + gid_val = nav.get("data-game-id") + gid_str = gid_val if isinstance(gid_val, str) else None + if not gid_str: + continue + + txt = nav.get_text(" ", strip=True) + if not txt: + continue + name = _MAP_NUMBER_RE.sub("", txt).strip() + + # Handle both "all" and numeric IDs + if gid_str == "all": + game_name_map["all"] = "All Maps" + elif gid_str.isdigit(): + game_name_map[int(gid_str)] = name + + # Determine order from nav + ordered_ids: list[str] = [] + nav_items = list(stats_root.select(".vm-stats-gamesnav .vm-stats-gamesnav-item")) + if nav_items: + temp_ids: list[str] = [] + for item in nav_items: + gid_val = item.get("data-game-id") + gid = gid_val if isinstance(gid_val, str) else None + if gid: + temp_ids.append(gid) + has_all = any(g == "all" for g in temp_ids) + numeric_ids: list[tuple[int, str]] = [] + for g in temp_ids: + if g != "all" and g.isdigit(): + try: + numeric_ids.append((int(g), g)) + except Exception: + continue + numeric_ids.sort(key=lambda x: x[0]) + # Skip "all" if there's only one match (it would be redundant) + include_all = has_all and len(numeric_ids) > 1 + ordered_ids = (["all"] if include_all else []) + [g for _, g in numeric_ids] + + if not ordered_ids: + ordered_ids = [] + for g in stats_root.select(".vm-stats-game"): + val = g.get("data-game-id") + s = val if isinstance(val, str) else None + ordered_ids.append(s or "") + + # Filter out "all" if there is only one actual match + numeric_count = sum(1 for x in ordered_ids if x != "all" and x.isdigit()) + if numeric_count <= 1 and "all" in ordered_ids: + ordered_ids = [x for x in ordered_ids if x != "all"] + + result: list[MapPerformance] = [] + section_by_id: dict[str, Tag] = {} + for g in stats_root.select(".vm-stats-game"): + key_val = g.get("data-game-id") + key = key_val if isinstance(key_val, str) else "" + section_by_id[key] = g + + for gid_raw in ordered_ids: + if limit is not None and len(result) >= limit: + break + game = section_by_id.get(gid_raw) + if game is None: + continue + + game_id_val = game.get("data-game-id") + game_id = game_id_val if isinstance(game_id_val, str) else None + gid: int | str | None = None + + if game_id == "all": + gid = "All" + map_name = game_name_map.get("all", "All Maps") + else: + try: + gid = int(game_id) if game_id and game_id.isdigit() else None + except Exception: + gid = None + map_name = game_name_map.get(gid) if gid is not None else None + + if not map_name: + header = game.select_one(".vm-stats-game-header .map") + if header: + outer = header.select_one("span") + if outer: + direct = outer.find(string=True, recursive=False) + map_name = (direct or "").strip() or None + + # Parse kill matrices + kill_matrix = _parse_kill_matrix(game, "mod-normal", team_tag_to_id) + fkfd_matrix = _parse_kill_matrix(game, "mod-fkfd", team_tag_to_id) + op_matrix = _parse_kill_matrix(game, "mod-op", team_tag_to_id) + + # Parse player performances from advanced stats table + player_performances = _parse_player_performances(game, team_tag_to_id) + + # Skip maps with no data (unplayed maps) + has_data = ( + len(kill_matrix) > 0 or + len(fkfd_matrix) > 0 or + len(op_matrix) > 0 or + len(player_performances) > 0 + ) + + if not has_data: + continue + + result.append(MapPerformance( + game_id=gid, + map_name=map_name, + kill_matrix=kill_matrix, + fkfd_matrix=fkfd_matrix, + op_matrix=op_matrix, + player_performances=player_performances, + )) + + return result + + +def _parse_kill_matrix(game: Tag, matrix_class: str, team_tag_to_id: dict[str, int | None]) -> list[KillMatrixEntry]: + """Parse a kill matrix table (normal, fkfd, or op).""" + entries: list[KillMatrixEntry] = [] + + # Find the table with the specified class + table = game.select_one(f"table.wf-table-inset.mod-matrix.{matrix_class}") + if not table: + return entries + + # Try tbody first, fall back to table directly + tbody = table.select_one("tbody") + container = tbody if tbody else table + + rows = container.select("tr") + if not rows: + return entries + + # First row contains victim headers + header_row = rows[0] + victim_cells = header_row.select("td")[1:] # Skip first empty cell + victims: list[tuple[str, str | None, int | None]] = [] # (name, team_short, team_id) + + for cell in victim_cells: + team_div = cell.select_one(".team") + if team_div: + # Extract player name + name_div = team_div.select_one("div") + if name_div: + # The player name is in the first text node + name_text = name_div.find(string=True, recursive=False) + name = (name_text or "").strip() if name_text else None + # Team tag is in the .team-tag element + team_tag_el = name_div.select_one(".team-tag") + team_tag = extract_text(team_tag_el) if team_tag_el else None + # Get team ID from tag + team_id = team_tag_to_id.get(team_tag.upper()) if team_tag else None + if name: + victims.append((name, team_tag, team_id)) + + # Remaining rows contain killer data + for row in rows[1:]: + cells = row.select("td") + if not cells: + continue + + # First cell is the killer + killer_cell = cells[0] + killer_team_div = killer_cell.select_one(".team") + if not killer_team_div: + continue + + killer_name_div = killer_team_div.select_one("div") + if not killer_name_div: + continue + + killer_name_text = killer_name_div.find(string=True, recursive=False) + killer_name = (killer_name_text or "").strip() if killer_name_text else None + killer_team_tag_el = killer_name_div.select_one(".team-tag") + killer_team_tag = extract_text(killer_team_tag_el) if killer_team_tag_el else None + killer_team_id = team_tag_to_id.get(killer_team_tag.upper()) if killer_team_tag else None + + if not killer_name: + continue + + # Remaining cells are stats against each victim + stat_cells = cells[1:] + for i, stat_cell in enumerate(stat_cells): + if i >= len(victims): + break + + victim_name, victim_team, victim_team_id = victims[i] + + # Parse the stats: three divs for kills, deaths, differential + stat_divs = stat_cell.select(".stats-sq") + if len(stat_divs) >= 3: + kills_text = extract_text(stat_divs[0]) + deaths_text = extract_text(stat_divs[1]) + diff_text = extract_text(stat_divs[2]) + + kills = parse_int(kills_text) + deaths = parse_int(deaths_text) + differential = parse_int(diff_text) + + # Only add entry if there's actual data + if kills is not None or deaths is not None: + # If victim team ID is still None, try to populate it from the killer's team info + # The table is structured so that players from the same team appear together + # Let's try to use a fallback method where if team IDs are missing, + # we assign them based on the team tags found in the table + final_killer_team_id = killer_team_id + final_victim_team_id = victim_team_id + + # If we still don't have team IDs, try to determine them from the team tags + # by checking if the tag exists in our mapping or trying to find other entries + if not final_killer_team_id and killer_team_tag and killer_team_tag in team_tag_to_id: + final_killer_team_id = team_tag_to_id[killer_team_tag] + if not final_victim_team_id and victim_team and victim_team in team_tag_to_id: + final_victim_team_id = team_tag_to_id[victim_team] + + entries.append(KillMatrixEntry( + killer_name=killer_name, + victim_name=victim_name, + killer_team_short=killer_team_tag, + killer_team_id=final_killer_team_id, + victim_team_short=victim_team, + victim_team_id=final_victim_team_id, + kills=kills, + deaths=deaths, + differential=differential, + )) + + return entries + + +def _parse_player_performances(game: Tag, team_tag_to_id: dict[str, int | None]) -> list[PlayerPerformance]: + """Parse player performance stats from the performance stats table.""" + performances: list[PlayerPerformance] = [] + + # Look for tables using multiple strategies to ensure we find the right one + table = None + + # Strategy 1: Look for table with performance notable elements (most specific) + for selector in [ + "table.wf-table-inset", + "table", + ".vm-stats-game table" # Limit search to game sections + ]: + tables = game.select(selector) + for t in tables: + # Check if this table contains performance notable elements (indicates detailed stats) + if t.select(".vm-perf-notable"): + table = t + break + if table: + break + + # Strategy 2: If not found, look for tables with both team elements and popable elements + if not table: + all_tables = game.select("table") + for t in all_tables: + if t.select_one(".team") and t.select(".wf-popable"): + table = t + break + + # Strategy 3: Look for tables that have the exact structure from the HTML + if not table: + # Look for tables that have stat squares with popable/perf-notable elements + all_tables = game.select("table") + for t in all_tables: + # Check if table has both team elements (for player names) and performance notables + if (t.select_one(".team") and + (t.select(".vm-perf-notable") or t.select(".wf-popable") or + any("mod-d" in (elem.get("class") or []) for elem in t.select(".stats-sq")))): + table = t + break + + # Strategy 4: Last resort - find any table with team elements and sufficient columns + if not table: + all_tables = game.select("table") + for t in all_tables: + # Look for any row in the table with team in first cell and enough stat cells + rows = t.select("tr") + for row in rows: + cells = row.select("td") + if len(cells) >= 7 and cells[0].select_one(".team"): + table = t + break + if table: + break + + if not table: + return performances + + # Try tbody first, fall back to table directly + tbody = table.select_one("tbody") + container = tbody if tbody else table + + rows = container.select("tr") + # Skip header row + data_rows = [r for r in rows if not r.select_one("th")] + + for row in data_rows: + cells = row.select("td") + # Check if we have minimum required cells + if len(cells) < 7: + continue + + # Cell 0: Player info + player_cell = cells[0] + team_div = player_cell.select_one(".team") + if not team_div: + continue + + div = team_div.select_one("div") + if not div: + continue + + # Player name is the first text node + name_text = div.find(string=True, recursive=False) + name = (name_text or "").strip() if name_text else None + if not name: + continue + + # Team tag + team_tag_el = div.select_one(".team-tag") + team_short = extract_text(team_tag_el) if team_tag_el else None + team_id = team_tag_to_id.get(team_short.upper()) if team_short else None + + # Cell 1: Agent + agent_cell = cells[1] + agent_img = agent_cell.select_one("img") + agent = None + if agent_img: + src_val = agent_img.get("src") + src = src_val if isinstance(src_val, str) else "" + # Extract agent name from path like "/img/vlr/game/agents/vyse.png" + if src: + parts = src.split("/") + if parts: + filename = parts[-1] + agent = filename.replace(".png", "").capitalize() + + # Parse all available stats based on actual column positions + # From the HTML you showed: 2K at position 2, 1v2 at position 7, etc. + multi_2k, multi_2k_details = _parse_detailed_stat_cell(cells[2]) if len(cells) > 2 else (None, None) + multi_3k, multi_3k_details = _parse_detailed_stat_cell(cells[3]) if len(cells) > 3 else (None, None) + multi_4k, multi_4k_details = _parse_detailed_stat_cell(cells[4]) if len(cells) > 4 else (None, None) + multi_5k, multi_5k_details = _parse_detailed_stat_cell(cells[5]) if len(cells) > 5 else (None, None) + + clutch_1v1, clutch_1v1_details = _parse_detailed_stat_cell(cells[6]) if len(cells) > 6 else (None, None) + clutch_1v2, clutch_1v2_details = _parse_detailed_stat_cell(cells[7]) if len(cells) > 7 else (None, None) + clutch_1v3, clutch_1v3_details = _parse_detailed_stat_cell(cells[8]) if len(cells) > 8 else (None, None) + clutch_1v4, clutch_1v4_details = _parse_detailed_stat_cell(cells[9]) if len(cells) > 9 else (None, None) + clutch_1v5, clutch_1v5_details = _parse_detailed_stat_cell(cells[10]) if len(cells) > 10 else (None, None) + + econ = _parse_stat_cell(cells[11]) if len(cells) > 11 else None + plants = _parse_stat_cell(cells[12]) if len(cells) > 12 else None + defuses = _parse_stat_cell(cells[13]) if len(cells) > 13 else None + + performances.append(PlayerPerformance( + name=name, + team_short=team_short, + team_id=team_id, + agent=agent, + multi_2k=multi_2k, + multi_3k=multi_3k, + multi_4k=multi_4k, + multi_5k=multi_5k, + clutch_1v1=clutch_1v1, + clutch_1v2=clutch_1v2, + clutch_1v3=clutch_1v3, + clutch_1v4=clutch_1v4, + clutch_1v5=clutch_1v5, + econ=econ, + plants=plants, + defuses=defuses, + multi_2k_details=multi_2k_details, + multi_3k_details=multi_3k_details, + multi_4k_details=multi_4k_details, + multi_5k_details=multi_5k_details, + clutch_1v1_details=clutch_1v1_details, + clutch_1v2_details=clutch_1v2_details, + clutch_1v3_details=clutch_1v3_details, + clutch_1v4_details=clutch_1v4_details, + clutch_1v5_details=clutch_1v5_details, + )) + + return performances + + +def _parse_detailed_stat_cell(cell: Tag): + """Parse a stat value and detailed information from a cell with popable contents.""" + stat_sq = cell.select_one(".stats-sq") + if not stat_sq: + return None, None + + # Extract the numerical value, handling whitespace properly + count = None + # Get the direct text content of the stats-sq element (excluding content from child elements) + # The number is the direct text content in the element + direct_text = None + + # Method 1: Try to get direct text node content (text not in child elements) + direct_children = [] + for content in stat_sq.contents: + if content.name is None: # This is a text node (not an HTML element) + text_content = str(content).strip() + if text_content: + direct_children.append(text_content) + + if direct_children: + # Take the first direct text content as the count + text_to_parse = direct_children[0] + count = parse_int(text_to_parse) + else: + # Method 2: If no direct text nodes, get direct text only (not from children) + # Loop through the direct children of the element to find text nodes + direct_text_content = "" + for child in stat_sq.children: + if isinstance(child, str) or (hasattr(child, 'strip') and child.name is None): + # This is a text node (not an element) + text_content = str(child).strip() + if text_content: + direct_text_content = text_content + break # Take the first direct text content + + if direct_text_content: + count = parse_int(direct_text_content) + + # If both methods failed, fall back to extract_text + if count is None: + text = extract_text(stat_sq).strip() + if text: + # Take only the first part if there are multiple values + first_part = text.split()[0] if text.split() else text + count = parse_int(first_part) + + # Extract detailed information from popable contents if present + details = [] + + popable_contents = stat_sq.select_one(".wf-popable-contents") + + if popable_contents: + # A single popable content might have multiple round entries + # Each round entry has a 'white-space: nowrap' div with the round number, + # followed by player elements + + # Look for all round elements (there may be multiple if the player had multiple multikills in different rounds) + round_divs = popable_contents.select("div[style*='white-space: nowrap']") + + for round_div in round_divs: + # Get the round number for this specific event + round_span = round_div.select_one("span") + round_number = None + if round_span: + round_text = extract_text(round_span) + round_number = parse_int(round_text) + + # Find the player elements that follow this specific round div + # We need to look for players that are related to this specific round + # The players are usually in the divs within the same container that follow the round div + players_killed = [] + + # Get all player elements in the same container as this round div + container = round_div.parent if round_div.parent else None + if container: + # Find all player elements in this container + all_player_elements = container.select("div[style*='display: flex; align-items: center']") + + for player_elem in all_player_elements: + player_text = extract_text(player_elem).strip() + if player_text: + # Extract just the player name (after the agent name/image) + parts = player_text.split() + # The player name is usually the last part after the image tag + # Example: "omen keiko" -> "keiko", "neon kamo" -> "kamo" + if len(parts) >= 2: + # Take the last part as the player name + player_name = parts[-1] + players_killed.append(player_name) + elif len(parts) == 1: + # If only one part, use it as player name + players_killed.append(parts[0]) + else: + # If it's blank for some reason, skip + continue + + # Create a detail entry for this specific round if we have both round and players + if round_number is not None and players_killed: + # Determine if this is a multi-kill or clutch based on the class modifiers + classes = stat_sq.get("class", []) + is_multikill = any(mod in classes for mod in ["mod-d", "mod-c", "mod-b", "mod-a"]) + is_clutch = any(mod in classes for mod in ["mod-e", "mod-f", "mod-g", "mod-h", "mod-i"]) + + from .models import MultiKillDetail, ClutchDetail + if is_multikill: + details.append(MultiKillDetail(round_number=round_number, players_killed=players_killed)) + elif is_clutch: + details.append(ClutchDetail(round_number=round_number, players_killed=players_killed)) + + return count, details if details else None + + +def _parse_stat_cell(cell: Tag) -> int | None: + """Parse a stat value from a cell.""" + stat_sq = cell.select_one(".stats-sq") + if not stat_sq: + return None + text = extract_text(stat_sq) + return parse_int(text) diff --git a/tests/lib/test_series.py b/tests/lib/test_series.py index d9e9469..6ca9b82 100644 --- a/tests/lib/test_series.py +++ b/tests/lib/test_series.py @@ -173,6 +173,72 @@ def test_team_metadata(self, mock_fetch_html): assert team.name is not None +class TestSeriesPerformance: + """Test series performance functionality.""" + + def test_performance_returns_list(self, mock_fetch_html): + """Test that performance() returns a list.""" + perf = vlr.series.performance(series_id=530935) + assert isinstance(perf, list) + + def test_performance_structure(self, mock_fetch_html): + """Test performance structure.""" + perf = vlr.series.performance(series_id=530935) + if perf: + game = perf[0] + assert hasattr(game, 'game_id') + assert hasattr(game, 'map_name') + assert hasattr(game, 'kill_matrix') + assert hasattr(game, 'fkfd_matrix') + assert hasattr(game, 'op_matrix') + assert hasattr(game, 'player_performances') + + def test_kill_matrix_structure(self, mock_fetch_html): + """Test kill matrix entry structure.""" + perf = vlr.series.performance(series_id=530935) + for game in perf: + if game.kill_matrix: + entry = game.kill_matrix[0] + assert hasattr(entry, 'killer_name') + assert hasattr(entry, 'victim_name') + assert hasattr(entry, 'kills') + assert hasattr(entry, 'deaths') + assert hasattr(entry, 'differential') + break + + def test_player_performance_structure(self, mock_fetch_html): + """Test player performance structure.""" + perf = vlr.series.performance(series_id=530935) + for game in perf: + if game.player_performances: + player = game.player_performances[0] + assert hasattr(player, 'name') + assert hasattr(player, 'team_short') + assert hasattr(player, 'agent') + assert hasattr(player, 'multi_2k') + assert hasattr(player, 'multi_3k') + assert hasattr(player, 'multi_4k') + assert hasattr(player, 'multi_5k') + assert hasattr(player, 'clutch_1v1') + assert hasattr(player, 'clutch_1v2') + assert hasattr(player, 'clutch_1v3') + assert hasattr(player, 'clutch_1v4') + assert hasattr(player, 'clutch_1v5') + assert hasattr(player, 'econ') + assert hasattr(player, 'plants') + assert hasattr(player, 'defuses') + break + + def test_performance_matrices(self, mock_fetch_html): + """Test that different matrix types are extracted.""" + perf = vlr.series.performance(series_id=530935) + for game in perf: + # Should have at least one matrix type + assert isinstance(game.kill_matrix, list) + assert isinstance(game.fkfd_matrix, list) + assert isinstance(game.op_matrix, list) + + class TestSeriesEdgeCases: """Test edge cases and error handling.""" @@ -185,3 +251,8 @@ def test_empty_maps(self, mock_fetch_html): """Test handling of empty maps list.""" maps = vlr.series.matches(series_id=999999999) assert isinstance(maps, list) + + def test_empty_performance(self, mock_fetch_html): + """Test handling of empty performance list.""" + perf = vlr.series.performance(series_id=999999999) + assert isinstance(perf, list) From c88e6b953f2f05e9afc21c28fa01b00d0b28f594 Mon Sep 17 00:00:00 2001 From: vanshbordia Date: Wed, 26 Nov 2025 22:09:19 +0530 Subject: [PATCH 3/3] Updated Docs for series.perfoemance --- betterdocs/content/docs/api/series/index.mdx | 26 +- .../content/docs/api/series/matches.mdx | 155 ++++- .../content/docs/api/series/performance.mdx | 515 ++++++++++++++++ betterdocs/content/docs/config.mdx | 39 +- betterdocs/content/docs/getting-started.mdx | 50 +- betterdocs/content/docs/index.mdx | 19 +- betterdocs/content/docs/installation.mdx | 15 +- betterdocs/content/docs/rate-limiting.mdx | 22 +- tests/lib/test_series.py | 553 +++++++++++++++++- 9 files changed, 1367 insertions(+), 27 deletions(-) create mode 100644 betterdocs/content/docs/api/series/performance.mdx diff --git a/betterdocs/content/docs/api/series/index.mdx b/betterdocs/content/docs/api/series/index.mdx index 66d6e86..cc6e045 100644 --- a/betterdocs/content/docs/api/series/index.mdx +++ b/betterdocs/content/docs/api/series/index.mdx @@ -4,7 +4,6 @@ description: Series header, picks/bans, per-map stats and player performance --- import { Cards, Card } from 'fumadocs-ui/components/card'; -import { Info, BarChart3 } from 'lucide-react'; The `vlrdevapi.series` module surfaces series/match details and rich per-map statistics. @@ -15,20 +14,23 @@ The `vlrdevapi.series` module surfaces series/match details and rich per-map sta href="/docs/api/series/info" title="series.info()" description="Get series header with teams, score, picks/bans" - icon={} /> } + description="Get per-map statistics and player performance" + /> + ## Quick Example -```python title="Get series details and map stats" +```python title="Get series details, map stats and performance data" tab="Code" import vlrdevapi as vlr # Get series header @@ -49,6 +51,20 @@ for map_data in maps: print(f"\nMap: {map_data.map_name}") for player in map_data.players[:5]: print(f" {player.name}: {player.acs} ACS, {player.k}/{player.d}/{player.a}") + +# Get detailed performance statistics +perf = vlr.series.performance(series_id=511536) +all_maps = next((game for game in perf if game.game_id == "All"), None) +if all_maps: + print(f"\nPerformance Summary:") + for player in all_maps.player_performances: + print(f" {player.name}: {player.multi_2k} 2Ks, {player.clutch_1v1} 1v1s, {player.econ} ECON") +``` + +```python tab="Output" +# Series header with teams and score +# Map statistics for each map in the series +# Performance summary with multi-kills and clutches ``` ## Source diff --git a/betterdocs/content/docs/api/series/matches.mdx b/betterdocs/content/docs/api/series/matches.mdx index 97d8f8b..68f3238 100644 --- a/betterdocs/content/docs/api/series/matches.mdx +++ b/betterdocs/content/docs/api/series/matches.mdx @@ -158,7 +158,7 @@ Returns a list of map statistics with player performance data. ### Get Map Statistics -```python title="Get map statistics" +```python title="Get map statistics" tab="Code" import vlrdevapi as vlr # Get all maps for a series @@ -175,9 +175,35 @@ for map_data in maps: print(f"Winner: {team1.name if team1.is_winner else team2.name}") ``` +```python tab="Output" + +Map: All +Players: 10 + +Map: Lotus +Players: 10 +Score: 13-10 +Winner: Velocity Gaming + +Map: Ascent +Players: 10 +Score: 7-13 +Winner: S8UL Esports + +Map: Haven +Players: 10 +Score: 13-6 +Winner: Velocity Gaming + +Map: Pearl +Players: 10 +Score: 13-11 +Winner: Velocity Gaming +``` + ### Analyze Player Performance -```python title="Analyze player performance" +```python title="Analyze player performance" tab="Code" import vlrdevapi as vlr # Get map stats @@ -203,9 +229,35 @@ if maps: print(f" Agent: {', '.join(player.agents)}") ``` +```python tab="Output" +=== All === + +Top performers: +damaraa: 256 ACS + K/D/A: 78/67/25 + Rating: 1.1 + Agent: Neon, Jett +miz: 229 ACS + K/D/A: 65/71/17 + Rating: 0.9 + Agent: Neon, Yoru, Jett +Lightningfast: 216 ACS + K/D/A: 74/53/30 + Rating: 1.28 + Agent: Omen, Astra +hellff: 201 ACS + K/D/A: 60/62/25 + Rating: 0.96 + Agent: Deadlock, Breach, Kayo +Techno: 199 ACS + K/D/A: 62/61/38 + Rating: 1.04 + Agent: Omen, Astra +``` + ### View Round-by-Round Results -```python title="View round-by-round results" +```python title="View round-by-round results" tab="Code" import vlrdevapi as vlr # Get map stats with rounds @@ -221,6 +273,103 @@ for map_data in maps: print(f"Round {round_result.number}: {winner} wins ({method}) - Score: {score}") ``` +```python tab="Output" + +=== Lotus - Round History === +Round 1: VLT wins (SpikeDefused) - Score: 1-0 +Round 2: VLT wins (Elimination) - Score: 2-0 +Round 3: S8UL wins (Elimination) - Score: 2-1 +Round 4: S8UL wins (SpikeExplosion) - Score: 2-2 +Round 5: S8UL wins (Elimination) - Score: 2-3 +Round 6: VLT wins (Elimination) - Score: 3-3 +Round 7: VLT wins (Elimination) - Score: 4-3 +Round 8: S8UL wins (SpikeExplosion) - Score: 4-4 +Round 9: S8UL wins (Elimination) - Score: 4-5 +Round 10: S8UL wins (SpikeExplosion) - Score: 4-6 +Round 11: VLT wins (SpikeDefused) - Score: 5-6 +Round 12: S8UL wins (Elimination) - Score: 5-7 +Round 13: VLT wins (Elimination) - Score: 6-7 +Round 14: VLT wins (Elimination) - Score: 7-7 +Round 15: VLT wins (SpikeExplosion) - Score: 8-7 +Round 16: VLT wins (Elimination) - Score: 9-7 +Round 17: VLT wins (Elimination) - Score: 10-7 +Round 18: VLT wins (Elimination) - Score: 11-7 +Round 19: S8UL wins (Elimination) - Score: 11-8 +Round 20: VLT wins (Elimination) - Score: 12-8 +Round 21: S8UL wins (Elimination) - Score: 12-9 +Round 22: S8UL wins (Elimination) - Score: 12-10 +Round 23: VLT wins (Elimination) - Score: 13-10 + +=== Ascent - Round History === +Round 1: S8UL wins (Elimination) - Score: 0-1 +Round 2: S8UL wins (Elimination) - Score: 0-2 +Round 3: S8UL wins (Elimination) - Score: 0-3 +Round 4: S8UL wins (Elimination) - Score: 0-4 +Round 5: S8UL wins (Elimination) - Score: 0-5 +Round 6: VLT wins (TimeRunOut) - Score: 1-5 +Round 7: S8UL wins (SpikeExplosion) - Score: 1-6 +Round 8: S8UL wins (Elimination) - Score: 1-7 +Round 9: VLT wins (Elimination) - Score: 2-7 +Round 10: VLT wins (TimeRunOut) - Score: 3-7 +Round 11: S8UL wins (Elimination) - Score: 3-8 +Round 12: VLT wins (Elimination) - Score: 4-8 +Round 13: VLT wins (Elimination) - Score: 5-8 +Round 14: VLT wins (Elimination) - Score: 6-8 +Round 15: S8UL wins (Elimination) - Score: 6-9 +Round 16: S8UL wins (Elimination) - Score: 6-10 +Round 17: S8UL wins (Elimination) - Score: 6-11 +Round 18: VLT wins (Elimination) - Score: 7-11 +Round 19: S8UL wins (Elimination) - Score: 7-12 +Round 20: S8UL wins (Elimination) - Score: 7-13 + +=== Haven - Round History === +Round 1: VLT wins (Elimination) - Score: 1-0 +Round 2: VLT wins (SpikeDefused) - Score: 2-0 +Round 3: S8UL wins (Elimination) - Score: 2-1 +Round 4: VLT wins (Elimination) - Score: 3-1 +Round 5: VLT wins (Elimination) - Score: 4-1 +Round 6: S8UL wins (Elimination) - Score: 4-2 +Round 7: S8UL wins (Elimination) - Score: 4-3 +Round 8: S8UL wins (Elimination) - Score: 4-4 +Round 9: S8UL wins (Elimination) - Score: 4-5 +Round 10: S8UL wins (Elimination) - Score: 4-6 +Round 11: VLT wins (Elimination) - Score: 5-6 +Round 12: VLT wins (SpikeDefused) - Score: 6-6 +Round 13: VLT wins (Elimination) - Score: 7-6 +Round 14: VLT wins (Elimination) - Score: 8-6 +Round 15: VLT wins (Elimination) - Score: 9-6 +Round 16: VLT wins (SpikeExplosion) - Score: 10-6 +Round 17: VLT wins (SpikeExplosion) - Score: 11-6 +Round 18: VLT wins (Elimination) - Score: 12-6 +Round 19: VLT wins (Elimination) - Score: 13-6 + +=== Pearl - Round History === +Round 1: VLT wins (Elimination) - Score: 1-0 +Round 2: VLT wins (Elimination) - Score: 2-0 +Round 3: S8UL wins (Elimination) - Score: 2-1 +Round 4: S8UL wins (Elimination) - Score: 2-2 +Round 5: VLT wins (Elimination) - Score: 3-2 +Round 6: VLT wins (Elimination) - Score: 4-2 +Round 7: S8UL wins (Elimination) - Score: 4-3 +Round 8: S8UL wins (Elimination) - Score: 4-4 +Round 9: S8UL wins (SpikeExplosion) - Score: 4-5 +Round 10: S8UL wins (Elimination) - Score: 4-6 +Round 11: VLT wins (Elimination) - Score: 5-6 +Round 12: VLT wins (Elimination) - Score: 6-6 +Round 13: S8UL wins (Elimination) - Score: 6-7 +Round 14: S8UL wins (SpikeDefused) - Score: 6-8 +Round 15: VLT wins (Elimination) - Score: 7-8 +Round 16: S8UL wins (Elimination) - Score: 7-9 +Round 17: VLT wins (Elimination) - Score: 8-9 +Round 18: VLT wins (Elimination) - Score: 9-9 +Round 19: S8UL wins (SpikeDefused) - Score: 9-10 +Round 20: VLT wins (SpikeExplosion) - Score: 10-10 +Round 21: VLT wins (Elimination) - Score: 11-10 +Round 22: VLT wins (SpikeExplosion) - Score: 12-10 +Round 23: S8UL wins (Elimination) - Score: 12-11 +Round 24: VLT wins (Elimination) - Score: 13-11 +``` + ## Error Handling diff --git a/betterdocs/content/docs/api/series/performance.mdx b/betterdocs/content/docs/api/series/performance.mdx new file mode 100644 index 0000000..bafb7c6 --- /dev/null +++ b/betterdocs/content/docs/api/series/performance.mdx @@ -0,0 +1,515 @@ +--- +title: series.performance() +description: Get detailed performance statistics for a series +--- + +import { TypeTable } from 'fumadocs-ui/components/type-table'; +import { Cards, Card } from 'fumadocs-ui/components/card'; +import { Info } from 'lucide-react'; + +## Signature + +```python +import vlrdevapi as vlr + +result = vlr.series.performance( + series_id: int, + limit: int | None = None, + timeout: float = 5.0 +) -> list[MapPerformance] +``` + +## Parameters + + + +## Return Value + +**Type:** `list[MapPerformance]` + +Returns a list of performance statistics for each map in the series (including an "All Maps" summary when multiple maps exist). + + + +**KillMatrixEntry fields:** + + + +**PlayerPerformance fields:** + + + +**MultiKillDetail fields:** + + + +**ClutchDetail fields:** + + + +## Examples + +### Get Performance Statistics + +```python title="Get performance statistics" tab="Code" +import vlrdevapi as vlr + +# Get performance data for a series +perf = vlr.series.performance(series_id=542272) + +for game in perf: + print(f"\nGame: {game.map_name} (ID: {game.game_id})") + print(f"Kill matrix entries: {len(game.kill_matrix)}") + print(f"Player performances: {len(game.player_performances)}") +``` + +```python tab="Output" + +Game: All Maps (ID: All ) +Kill matrix entries: 25 +Player performances: 10 + +Game: Corrode (ID: 233478 ) +Kill matrix entries: 25 +Player performances: 10 + +Game: Lotus (ID: 233479 ) +Kill matrix entries: 25 +Player performances: 10 + +Game: Abyss (ID: 233480 ) +Kill matrix entries: 25 +Player performances: 10 + +Game: Ascent (ID: 233481 ) +Kill matrix entries: 25 +Player performances: 10 + +Game: Sunset (ID: 233482 ) +Kill matrix entries: 25 +Player performances: 10 +``` + +### Analyze Kill Matrix + +```python title="Analyze kill matrix" tab="Code" +import vlrdevapi as vlr + +perf = vlr.series.performance(series_id=542272) + +# Find the All Maps performance data +all_maps = next((game for game in perf if game.game_id == "All"), None) + +if all_maps: + print("=== Kill Matrix Analysis ===") + + # Find top fraggers + player_kills = {} + for entry in all_maps.kill_matrix: + if entry.kills and entry.kills > 0: + player_kills[entry.killer_name] = player_kills.get(entry.killer_name, 0) + entry.kills + + for player, kills in sorted(player_kills.items(), key=lambda x: x[1], reverse=True): + print(f"{player}: {kills} total kills") +``` + +```python tab="Output" +=== Kill Matrix Analysis === +brawk: 84 total kills +Ethan: 77 total kills +skuba: 76 total kills +mada: 74 total kills +s0m: 63 total kills +``` + +### Player Performance Analysis + +```python title="Player performance analysis" tab="Code" +import vlrdevapi as vlr + +perf = vlr.series.performance(series_id=542272) +all_maps = next((game for game in perf if game.game_id == "All"), None) + +if all_maps: + print("=== Player Performance ===") + + for player in all_maps.player_performances: + print(f"\n{player.name} ({player.team_short}):") + print(f" Agent: {player.agent}") + print(f" Multi-kills: 2K={player.multi_2k}, 3K={player.multi_3k}, 4K={player.multi_4k}") + print(f" Clutches: 1v1={player.clutch_1v1}, 1v2={player.clutch_1v2}") + print(f" Economy: {player.econ}, Plants: {player.plants}, Defuses: {player.defuses}") +``` + +```python tab="Output" +=== Player Performance === + +brawk (NRG): + Agent: Sova + Multi-kills: 2K=12, 3K=8, 4K=None + Clutches: 1v1=1, 1v2=None + Economy: 66 , Plants: 4 , Defuses: 5 + +s0m (NRG): + Agent: Omen + Multi-kills: 2K=11, 3K=1, 4K=None + Clutches: 1v1=2, 1v2=None + Economy: 42 , Plants: 9 , Defuses: 0 + +mada (NRG): + Agent: Waylay + Multi-kills: 2K=12, 3K=6, 4K=None + Clutches: 1v1=None, 1v2=None + Economy: 48 , Plants: 1 , Defuses: 4 + +skuba (NRG): + Agent: Viper + Multi-kills: 2K=15, 3K=2, 4K=2 + Clutches: 1v1=2, 1v2=1 + Economy: 54 , Plants: 5 , Defuses: 5 + +Ethan (NRG): + Agent: Kayo + Multi-kills: 2K=10, 3K=5, 4K=2 + Clutches: 1v1=2, 1v2=None + Economy: 54 , Plants: 20 , Defuses: 1 + +crashies (FNC): + Agent: Fade + Multi-kills: 2K=12, 3K=None, 4K=2 + Clutches: 1v1=1, 1v2=None + Economy: 45 , Plants: 11 , Defuses: 2 + +Boaster (FNC): + Agent: Astra + Multi-kills: 2K=5, 3K=2, 4K=None + Clutches: 1v1=None, 1v2=1 + Economy: 35 , Plants: 12 , Defuses: 3 + +Chronicle (FNC): + Agent: Viper + Multi-kills: 2K=6, 3K=3, 4K=2 + Clutches: 1v1=1, 1v2=None + Economy: 45 , Plants: 2 , Defuses: 7 + +kaajak (FNC): + Agent: Yoru + Multi-kills: 2K=13, 3K=8, 4K=1 + Clutches: 1v1=1, 1v2=None + Economy: 58 , Plants: 4 , Defuses: 1 + +Alfajer (FNC): + Agent: Vyse + Multi-kills: 2K=14, 3K=4, 4K=None + Clutches: 1v1=1, 1v2=None + Economy: 51 , Plants: 5 , Defuses: 0 +``` + +### First Kill/First Death Analysis + +```python title="First kill analysis" tab="Code" +import vlrdevapi as vlr + +perf = vlr.series.performance(series_id=542272) +all_maps = next((game for game in perf if game.game_id == "All"), None) + +if all_maps: + print("=== First Kill/First Death Analysis ===") + + # Calculate first kill differentials + fk_diffs = {} + for entry in all_maps.fkfd_matrix: + if entry.kills or entry.deaths: + player = entry.killer_name + diff = (entry.kills or 0) - (entry.deaths or 0) + fk_diffs[player] = fk_diffs.get(player, 0) + diff + + for player, diff in sorted(fk_diffs.items(), key=lambda x: x[1], reverse=True): + print(f"{player}: {diff:+d} FK/FD differential") +``` + +```python tab="Output" +=== First Kill/First Death Analysis === +skuba: 3 FK/FD differential +brawk: 2 FK/FD differential +mada: 2 FK/FD differential +s0m: -3 FK/FD differential +Ethan: -4 FK/FD differential +``` + +### Multi-Kill Details + +```python title="Multi-kill details" tab="Code" +import vlrdevapi as vlr + +perf = vlr.series.performance(series_id=542272) +all_maps = next((game for game in perf if game.map_name == "Corrode"), None) + +if all_maps: + for player in all_maps.player_performances: + if player.multi_4k_details: + print(f"\n{player.name} 4Ks:") + for detail in player.multi_4k_details: + print(f" Round {detail.round_number}: killed {', '.join(detail.players_killed)}") +``` + +```python tab="Output" +# No 4K details found in this series +``` + +## Matrix Types + +The performance data includes three different kill matrices: + +- **Normal Kill Matrix** (`kill_matrix`): All kill/death interactions between players +- **First Kill/First Death Matrix** (`fkfd_matrix`): Opening kills and first deaths only +- **Operator Kill Matrix** (`op_matrix`): Kills made with Operator sniper rifle only + +## Error Handling + +- **Network failures**: Returns an empty list `[]` +- **Invalid series ID**: Returns an empty list `[]` +- **No performance data**: Returns an empty list `[]` + +The function never raises exceptions, making it safe to use without try-catch blocks. + +## Related + + + } + /> + } + /> + + +## Source + +Data scraped from: `https://www.vlr.gg/{series_id}?game=all&tab=performance` diff --git a/betterdocs/content/docs/config.mdx b/betterdocs/content/docs/config.mdx index 9588874..d87848d 100644 --- a/betterdocs/content/docs/config.mdx +++ b/betterdocs/content/docs/config.mdx @@ -125,7 +125,7 @@ Below are the core configuration fields and helper functions with their Python t ### Increase timeout and retries -```python +```python tab="Code" import vlrdevapi as vlr # Increase default timeout and retry behavior @@ -135,27 +135,39 @@ vlr.configure(default_timeout=10.0, max_retries=5, backoff_factor=2.0) events = vlr.events.list_events(limit=5) ``` +```python tab="Output" +Events found: 5 +``` + ### Custom User-Agent -```python +```python tab="Code" import vlrdevapi as vlr vlr.configure(default_user_agent="MyApp/1.0 (+https://example.com)") ``` +```python tab="Output" +User agent configured +``` + ### Change base URL (advanced) -```python +```python tab="Code" import vlrdevapi as vlr vlr.configure(vlr_base="https://my-proxy.example.com") ``` +```python tab="Output" +# Base URL configured to use proxy +``` + ## Rate Limiting The client uses a lightweight token-bucket limiter. You can change whether it is enabled and what rate to use. -```python +```python tab="Code" import vlrdevapi as vlr # Disable @@ -171,25 +183,40 @@ vlr.configure_rate_limit(requests_per_second=5, enabled=True) vlr.reset_rate_limit() ``` +```python tab="Output" +# Rate limit disabled: 0.0 +# Rate limit enabled at 20 RPS: 20.0 +# Temporary override at 5 RPS: 5.0 +# Reset to defaults: 10.0 +``` + ## Reset to defaults -```python +```python tab="Code" import vlrdevapi as vlr vlr.reset_config() ``` +```python tab="Output" +# Configuration reset to library defaults +``` + ## Per-call overrides You can still override settings per-call on `fetcher` functions. -```python +```python tab="Code" from vlrdevapi.fetcher import fetch_html # Override timeout just for this request html = fetch_html("https://www.vlr.gg/matches", timeout=2.5) ``` +```python tab="Output" +# HTML fetched with custom timeout of 2.5 seconds +``` + ## Related diff --git a/betterdocs/content/docs/getting-started.mdx b/betterdocs/content/docs/getting-started.mdx index e65253b..188688d 100644 --- a/betterdocs/content/docs/getting-started.mdx +++ b/betterdocs/content/docs/getting-started.mdx @@ -30,7 +30,7 @@ uv add vlrdevapi ## Import and Verify -```python title="Quick Verification" +```python title="Quick Verification" tab="Code" import vlrdevapi as vlr # Check if VLR.gg is reachable @@ -40,6 +40,10 @@ else: print("VLR.gg is currently unreachable") ``` +```python tab="Output" +Ready to use vlrdevapi! +``` + @@ -49,7 +53,7 @@ else: -```python title="Get Upcoming Matches" +```python title="Get Upcoming Matches" tab="Code" import vlrdevapi as vlr # Get next 5 upcoming matches @@ -61,10 +65,28 @@ for m in matches: print(f" Time: {m.time}") ``` +```python tab="Output" +Game Changers 2025: Championship Seoul - Main EventUpper Semifinals + Shopify Rebellion Gold vs Karmine Corp GC + Time: 3:00 AM +Game Changers 2025: Championship Seoul - Main EventLower Round 3 + TBD vs MIBR GC + Time: 12:00 AM +Game Changers 2025: Championship Seoul - Main EventLower Round 3 + TBD vs KR BLAZE + Time: 3:00 AM +Game Changers 2025: Championship Seoul - Main EventUpper Final + TBD vs TBD + Time: 12:00 AM +Game Changers 2025: Championship Seoul - Main EventLower Round 4 + TBD vs TBD + Time: 3:00 AM +``` + -```python title="Search for Teams and Players" +```python title="Search for Teams and Players" tab="Code" import vlrdevapi as vlr # Search across all categories @@ -76,10 +98,17 @@ print(f" Teams: {len(results.teams)}") print(f" Events: {len(results.events)}") ``` +```python tab="Output" +Found 7 results: + Players: 0 + Teams: 1 + Events: 6 +``` + -```python title="List Ongoing Events" +```python title="List Ongoing Events" tab="Code" import vlrdevapi as vlr from vlrdevapi.events import EventTier, EventStatus @@ -94,6 +123,10 @@ for e in events: print(f"{e.name} - {e.prize or 'TBD'}") ``` +```python tab="Output" +Events found: 0 +``` + @@ -105,7 +138,7 @@ for e in events: By default, vlrdevapi limits requests to **10 per second**. Adjust if needed: -```python title="Configure Rate Limiting" +```python title="Configure Rate Limiting" tab="Code" import vlrdevapi as vlr # Check current RPS (0.0 means disabled) @@ -121,6 +154,13 @@ vlr.configure_rate_limit(enabled=False) vlr.reset_rate_limit() ``` +```python tab="Output" +10.0 +5.0 +0.0 +10.0 +``` + Keep rate limiting enabled. The default of 10 requests/second is a good balance for most use cases. diff --git a/betterdocs/content/docs/index.mdx b/betterdocs/content/docs/index.mdx index 199d7bc..2abce26 100644 --- a/betterdocs/content/docs/index.mdx +++ b/betterdocs/content/docs/index.mdx @@ -18,7 +18,7 @@ VLR Dev API is a typed Python client for Valorant esports data scraped from VLR. ## Quick Start -```python title="Quick Start" +```python title="Quick Start" tab="Code" import vlrdevapi as vlr # Upcoming matches @@ -27,6 +27,14 @@ for m in matches: print(m.event, '-', m.team1.name, 'vs', m.team2.name) ``` +```python tab="Output" +Game Changers 2025: Championship Seoul - Main EventUpper Semifinals - Shopify Rebellion Gold vs Karmine Corp GC +Game Changers 2025: Championship Seoul - Main EventLower Round 3 - TBD vs MIBR GC +Game Changers 2025: Championship Seoul - Main EventLower Round 3 - TBD vs KR BLAZE +Game Changers 2025: Championship Seoul - Main EventUpper Final - TBD vs TBD +Game Changers 2025: Championship Seoul - Main EventLower Round 4 - TBD vs TBD +``` + ## Get Started @@ -93,7 +101,7 @@ for m in matches: ## Example: End-to-end -```python title="Lookup a team and upcoming matches" +```python title="Lookup a team and upcoming matches" tab="Code" import vlrdevapi as vlr team = vlr.teams.info(team_id=1034) @@ -109,6 +117,13 @@ if team: print(f" {match.tournament_name}: {match.team1.name} vs {match.team2.name}") ``` +```python tab="Output" +NRG (NRG) - United States +Roster: 8 players + +Next matches: +``` + ## GitHub
diff --git a/betterdocs/content/docs/installation.mdx b/betterdocs/content/docs/installation.mdx index 2c94a66..1cf162d 100644 --- a/betterdocs/content/docs/installation.mdx +++ b/betterdocs/content/docs/installation.mdx @@ -18,7 +18,7 @@ uv add vlrdevapi ## Verify -```python +```python tab="Code" import vlrdevapi as vlr print(vlr.__version__) @@ -31,11 +31,16 @@ except Exception: pass ``` +```python tab="Output" +1.4.0 +True +``` + If the status helper (when available) returns `False`, VLR.gg may be down or blocked. ## Optional: Configure Rate Limiting -```python +```python tab="Code" import vlrdevapi as vlr # Check current RPS (0.0 means disabled) @@ -48,4 +53,10 @@ vlr.configure_rate_limit(requests_per_second=5.0, enabled=True) vlr.reset_rate_limit() ``` +```python tab="Output" +10.0 +5.0 +10.0 +``` + See: [Rate Limiting](/docs/rate-limiting) diff --git a/betterdocs/content/docs/rate-limiting.mdx b/betterdocs/content/docs/rate-limiting.mdx index 53facf1..11f8032 100644 --- a/betterdocs/content/docs/rate-limiting.mdx +++ b/betterdocs/content/docs/rate-limiting.mdx @@ -15,7 +15,7 @@ vlrdevapi provides a built-in, global rate limiter so you can avoid overwhelming ## API -```python title="Public helpers" +```python title="Public helpers" tab="Code" import vlrdevapi as vlr # Get current RPS (0.0 means disabled) @@ -29,9 +29,16 @@ vlr.configure_rate_limit(enabled=False) # disable vlr.reset_rate_limit() ``` +```python tab="Output" +# Current RPS: 10.0 +# After setting to 5.0: 5.0 +# After disabling: 0.0 +# After reset: 10.0 +``` + ## Examples -```python title="Lower rate for sensitive flows" +```python title="Lower rate for sensitive flows" tab="Code" import vlrdevapi as vlr # Temporarily reduce to 3 RPS @@ -43,7 +50,12 @@ vlr.configure_rate_limit(requests_per_second=3.0, enabled=True) vlr.reset_rate_limit() ``` -```python title="Disable briefly (not recommended)" +```python tab="Output" +# Rate limit temporarily set to 3.0 requests/second +# After reset: 10.0 requests/second (default) +``` + +```python title="Disable briefly (not recommended)" tab="Code" import vlrdevapi as vlr # Disable limiter (0.0 when disabled) @@ -54,6 +66,10 @@ print(vlr.get_rate_limit()) # 0.0 vlr.reset_rate_limit() ``` +```python tab="Output" +0.0 +``` + ## Error Handling - The limiter is applied automatically by all network calls. diff --git a/tests/lib/test_series.py b/tests/lib/test_series.py index 6ca9b82..72515d5 100644 --- a/tests/lib/test_series.py +++ b/tests/lib/test_series.py @@ -175,7 +175,53 @@ def test_team_metadata(self, mock_fetch_html): class TestSeriesPerformance: """Test series performance functionality.""" - + + def test_performance_real_api_chronicle_match(self): + """Test real API performance data for Chronicle FNC vs NRG (series 542272).""" + perf = vlr.series.performance(series_id=542272) + assert isinstance(perf, list) + assert len(perf) > 0 + + # Find the All Maps performance data + all_maps_perf = None + for game in perf: + if game.game_id == "All": + all_maps_perf = game + break + + assert all_maps_perf is not None + + # Test brawk from NRG: has 22 kills and 15 deaths with diff of 7 (against Chronicle) + brawk_entries = [entry for entry in all_maps_perf.kill_matrix + if entry.killer_name.lower() == "brawk" and entry.killer_team_short == "NRG"] + + assert len(brawk_entries) > 0 + + # Check if brawk has 22 kills and 15 deaths with diff 7 against Chronicle + chronicle_entry = [entry for entry in brawk_entries if entry.victim_name.lower() == "chronicle" and entry.kills == 22 and entry.deaths == 15 and entry.differential == 7] + assert len(chronicle_entry) > 0 + + # Test FKFD matrix: 2 to 1 with diff of 1 + fkfd_entries = [entry for entry in all_maps_perf.fkfd_matrix if entry.kills == 2 and entry.deaths == 1] + assert len(fkfd_entries) > 0 + fkfd_entry = fkfd_entries[0] + assert fkfd_entry.differential == 1 + + # Test OP kills: mada from NRG has OP kills, Boaster from FNC has 0 + mada_op_entries = [entry for entry in all_maps_perf.op_matrix + if entry.killer_name.lower() == "mada" and entry.killer_team_short == "NRG"] + assert len(mada_op_entries) > 0 + + # Sum all of mada's OP kills + mada_total_op_kills = sum(entry.kills for entry in mada_op_entries if entry.kills) + assert mada_total_op_kills > 0 # mada has OP kills + + # Boaster from FNC should have no OP kills + boaster_op_entries = [entry for entry in all_maps_perf.op_matrix + if entry.killer_name.lower() == "boaster" and entry.killer_team_short == "FNC"] + boaster_total_op_kills = sum(entry.kills for entry in boaster_op_entries if entry.kills) if boaster_op_entries else 0 + assert boaster_total_op_kills == 0 + def test_performance_returns_list(self, mock_fetch_html): """Test that performance() returns a list.""" perf = vlr.series.performance(series_id=530935) @@ -256,3 +302,508 @@ def test_empty_performance(self, mock_fetch_html): """Test handling of empty performance list.""" perf = vlr.series.performance(series_id=999999999) assert isinstance(perf, list) + + +class TestSeriesPerformanceAdvanced: + """Advanced tests for series performance functionality.""" + + def test_performance_limit_parameter(self, mock_fetch_html): + """Test performance() function with limit parameter.""" + perf = vlr.series.performance(series_id=530935, limit=2) + assert isinstance(perf, list) + assert len(perf) <= 2 + + def test_performance_timeout_parameter(self, mock_fetch_html): + """Test performance() function with timeout parameter.""" + perf = vlr.series.performance(series_id=530935, timeout=30.0) + assert isinstance(perf, list) + + def test_performance_game_id_mapping(self, mock_fetch_html): + """Test that game IDs are correctly mapped to map names.""" + perf = vlr.series.performance(series_id=530935) + if perf: + for game in perf: + assert hasattr(game, 'game_id') + assert hasattr(game, 'map_name') + # game_id should be int for individual maps, "All" for combined + if game.game_id == "All": + assert game.map_name in ["All Maps", "All"] + elif isinstance(game.game_id, int): + assert isinstance(game.map_name, str) + assert len(game.map_name) > 0 + + def test_performance_all_maps_inclusion(self, mock_fetch_html): + """Test that 'All Maps' is included when multiple maps exist.""" + perf = vlr.series.performance(series_id=530935) + if perf and len(perf) > 1: + # Should have "All Maps" when there are multiple individual maps + all_maps_found = any(game.game_id == "All" for game in perf) + individual_maps = [game for game in perf if isinstance(game.game_id, int)] + if len(individual_maps) > 1: + assert all_maps_found, "All Maps should be included when multiple maps exist" + + def test_kill_matrix_complete_structure(self, mock_fetch_html): + """Test complete kill matrix entry structure with all fields.""" + perf = vlr.series.performance(series_id=530935) + for game in perf: + if game.kill_matrix: + entry = game.kill_matrix[0] + # Test all required fields + assert hasattr(entry, 'killer_name') + assert hasattr(entry, 'victim_name') + assert hasattr(entry, 'killer_team_short') + assert hasattr(entry, 'killer_team_id') + assert hasattr(entry, 'victim_team_short') + assert hasattr(entry, 'victim_team_id') + assert hasattr(entry, 'kills') + assert hasattr(entry, 'deaths') + assert hasattr(entry, 'differential') + + # Test data types + assert isinstance(entry.killer_name, str) + assert isinstance(entry.victim_name, str) + assert entry.killer_team_short is None or isinstance(entry.killer_team_short, str) + assert entry.killer_team_id is None or isinstance(entry.killer_team_id, int) + assert entry.victim_team_short is None or isinstance(entry.victim_team_short, str) + assert entry.victim_team_id is None or isinstance(entry.victim_team_id, int) + assert entry.kills is None or isinstance(entry.kills, int) + assert entry.deaths is None or isinstance(entry.deaths, int) + assert entry.differential is None or isinstance(entry.differential, int) + + def test_fkfd_matrix_structure(self, mock_fetch_html): + """Test first kill/first death matrix structure.""" + perf = vlr.series.performance(series_id=530935) + for game in perf: + if game.fkfd_matrix: + entry = game.fkfd_matrix[0] + # FKFD matrix should have same structure as kill matrix + assert hasattr(entry, 'killer_name') + assert hasattr(entry, 'victim_name') + assert hasattr(entry, 'kills') + assert hasattr(entry, 'deaths') + assert hasattr(entry, 'differential') + + def test_op_matrix_structure(self, mock_fetch_html): + """Test operator (OP) matrix structure.""" + perf = vlr.series.performance(series_id=530935) + for game in perf: + if game.op_matrix: + entry = game.op_matrix[0] + # OP matrix should have same structure as kill matrix + assert hasattr(entry, 'killer_name') + assert hasattr(entry, 'victim_name') + assert hasattr(entry, 'kills') + assert hasattr(entry, 'deaths') + assert hasattr(entry, 'differential') + + def test_player_performance_complete_stats(self, mock_fetch_html): + """Test complete player performance statistics.""" + perf = vlr.series.performance(series_id=530935) + for game in perf: + if game.player_performances: + player = game.player_performances[0] + # Test all basic fields + assert hasattr(player, 'name') + assert hasattr(player, 'team_short') + assert hasattr(player, 'team_id') + assert hasattr(player, 'agent') + + # Test all multi-kill fields + assert hasattr(player, 'multi_2k') + assert hasattr(player, 'multi_3k') + assert hasattr(player, 'multi_4k') + assert hasattr(player, 'multi_5k') + + # Test all clutch fields + assert hasattr(player, 'clutch_1v1') + assert hasattr(player, 'clutch_1v2') + assert hasattr(player, 'clutch_1v3') + assert hasattr(player, 'clutch_1v4') + assert hasattr(player, 'clutch_1v5') + + # Test economy and objective fields + assert hasattr(player, 'econ') + assert hasattr(player, 'plants') + assert hasattr(player, 'defuses') + + # Test detail fields + assert hasattr(player, 'multi_2k_details') + assert hasattr(player, 'multi_3k_details') + assert hasattr(player, 'multi_4k_details') + assert hasattr(player, 'multi_5k_details') + assert hasattr(player, 'clutch_1v1_details') + assert hasattr(player, 'clutch_1v2_details') + assert hasattr(player, 'clutch_1v3_details') + assert hasattr(player, 'clutch_1v4_details') + assert hasattr(player, 'clutch_1v5_details') + + def test_player_performance_agent_extraction(self, mock_fetch_html): + """Test that agent names are correctly extracted from image paths.""" + perf = vlr.series.performance(series_id=530935) + for game in perf: + if game.player_performances: + for player in game.player_performances: + if player.agent: + # Agent should be a capitalized string + assert isinstance(player.agent, str) + assert len(player.agent) > 0 + # Should be capitalized (first letter uppercase) + assert player.agent[0].isupper() + + def test_multi_kill_details_structure(self, mock_fetch_html): + """Test multi-kill detail structure when available.""" + perf = vlr.series.performance(series_id=530935) + for game in perf: + if game.player_performances: + for player in game.player_performances: + # Check any multi-kill details + for detail_field in ['multi_2k_details', 'multi_3k_details', 'multi_4k_details', 'multi_5k_details']: + details = getattr(player, detail_field) + if details: + for detail in details: + assert hasattr(detail, 'round_number') + assert hasattr(detail, 'players_killed') + assert isinstance(detail.round_number, int) + assert isinstance(detail.players_killed, list) + assert len(detail.players_killed) > 0 + + def test_clutch_details_structure(self, mock_fetch_html): + """Test clutch detail structure when available.""" + perf = vlr.series.performance(series_id=530935) + for game in perf: + if game.player_performances: + for player in game.player_performances: + # Check any clutch details + for detail_field in ['clutch_1v1_details', 'clutch_1v2_details', 'clutch_1v3_details', 'clutch_1v4_details', 'clutch_1v5_details']: + details = getattr(player, detail_field) + if details: + for detail in details: + assert hasattr(detail, 'round_number') + assert hasattr(detail, 'players_killed') + assert isinstance(detail.round_number, int) + assert isinstance(detail.players_killed, list) + + def test_performance_economy_stats(self, mock_fetch_html): + """Test economy statistics parsing.""" + perf = vlr.series.performance(series_id=530935) + for game in perf: + if game.player_performances: + for player in game.player_performances: + # Test economy stats + if player.econ is not None: + assert isinstance(player.econ, int) + assert player.econ >= 0 + + # Test objective stats + if player.plants is not None: + assert isinstance(player.plants, int) + assert player.plants >= 0 + + if player.defuses is not None: + assert isinstance(player.defuses, int) + assert player.defuses >= 0 + + def test_performance_team_id_mapping(self, mock_fetch_html): + """Test that team IDs are correctly mapped from team tags.""" + perf = vlr.series.performance(series_id=530935) + for game in perf: + if game.kill_matrix: + for entry in game.kill_matrix: + # If we have team short names, we should ideally have team IDs + if entry.killer_team_short and entry.killer_team_id: + assert isinstance(entry.killer_team_id, int) + if entry.victim_team_short and entry.victim_team_id: + assert isinstance(entry.victim_team_id, int) + + def test_performance_data_consistency(self, mock_fetch_html): + """Test that performance data is internally consistent.""" + perf = vlr.series.performance(series_id=530935) + if perf: + # All games should have valid game_id and map_name + for game in perf: + assert game.game_id is not None + assert game.map_name is not None + + # Player performances should have consistent team info + if game.player_performances: + for player in game.player_performances: + if player.team_short and player.team_id: + # Team ID should be consistent across players from same team + teammates = [p for p in game.player_performances + if p.team_short == player.team_short and p.team_id] + for teammate in teammates: + assert teammate.team_id == player.team_id + + def test_performance_empty_data_handling(self, mock_fetch_html): + """Test handling of games with no performance data.""" + perf = vlr.series.performance(series_id=530935) + # Should return list even if some games have no data + assert isinstance(perf, list) + + # Games with no actual data should be filtered out + for game in perf: + has_any_data = ( + len(game.kill_matrix) > 0 or + len(game.fkfd_matrix) > 0 or + len(game.op_matrix) > 0 or + len(game.player_performances) > 0 + ) + assert has_any_data, "Games with no data should be filtered out" + + +class TestSeriesPerformanceValidation: + """Specific data validation tests for performance module.""" + + def test_performance_542272_brawk_chronicle_kill_matrix(self): + """Test specific kill matrix data for game 542272: brawk vs Chronicle.""" + perf = vlr.series.performance(series_id=542272) + assert isinstance(perf, list) + assert len(perf) > 0 + + # Find the All Maps performance data + all_maps_perf = None + for game in perf: + if game.game_id == "All": + all_maps_perf = game + break + + assert all_maps_perf is not None, "All Maps performance data should be available" + + # Test brawk from NRG: has 22 kills and 15 deaths with diff of 7 (against Chronicle) + brawk_entries = [entry for entry in all_maps_perf.kill_matrix + if entry.killer_name.lower() == "brawk" and + entry.killer_team_short == "NRG" and + entry.victim_name.lower() == "chronicle" and + entry.victim_team_short == "FNC"] + + assert len(brawk_entries) > 0, "Should find brawk vs Chronicle entry in kill matrix" + + brawk_chronicle_entry = brawk_entries[0] + assert brawk_chronicle_entry.kills == 22, f"brawk should have 22 kills against Chronicle, got {brawk_chronicle_entry.kills}" + assert brawk_chronicle_entry.deaths == 15, f"brawk should have 15 deaths against Chronicle, got {brawk_chronicle_entry.deaths}" + assert brawk_chronicle_entry.differential == 7, f"brawk should have differential of 7 against Chronicle, got {brawk_chronicle_entry.differential}" + + def test_performance_542272_brawk_chronicle_fkfd_matrix(self): + """Test specific FKFD matrix data for game 542272: brawk vs Chronicle.""" + perf = vlr.series.performance(series_id=542272) + assert isinstance(perf, list) + + # Find the All Maps performance data + all_maps_perf = None + for game in perf: + if game.game_id == "All": + all_maps_perf = game + break + + assert all_maps_perf is not None + + # Test FKFD matrix: 2 to 1 with diff of 1 + fkfd_entries = [entry for entry in all_maps_perf.fkfd_matrix + if entry.killer_name.lower() == "brawk" and + entry.killer_team_short == "NRG" and + entry.victim_name.lower() == "chronicle" and + entry.victim_team_short == "FNC"] + + assert len(fkfd_entries) > 0, "Should find brawk vs Chronicle entry in FKFD matrix" + + fkfd_entry = fkfd_entries[0] + assert fkfd_entry.kills == 2, f"brawk should have 2 first kills against Chronicle, got {fkfd_entry.kills}" + assert fkfd_entry.deaths == 1, f"brawk should have 1 first death against Chronicle, got {fkfd_entry.deaths}" + assert fkfd_entry.differential == 1, f"brawk should have differential of 1 in FKFD against Chronicle, got {fkfd_entry.differential}" + + def test_performance_542272_mada_boaster_op_matrix(self): + """Test specific OP matrix data for game 542272: mada vs Boaster.""" + perf = vlr.series.performance(series_id=542272) + assert isinstance(perf, list) + + # Find the All Maps performance data + all_maps_perf = None + for game in perf: + if game.game_id == "All": + all_maps_perf = game + break + + assert all_maps_perf is not None + + # Test OP kills: mada from NRG has OP kills, Boaster from FNC has 0 + mada_op_entries = [entry for entry in all_maps_perf.op_matrix + if entry.killer_name.lower() == "mada" and + entry.killer_team_short == "NRG"] + + assert len(mada_op_entries) > 0, "Should find mada entries in OP matrix" + + # Sum all of mada's OP kills + mada_total_op_kills = sum(entry.kills for entry in mada_op_entries if entry.kills) + assert mada_total_op_kills >= 1, f"mada should have at least 1 OP kill, got {mada_total_op_kills}" + + # Boaster from FNC should have no OP kills + boaster_op_entries = [entry for entry in all_maps_perf.op_matrix + if entry.killer_name.lower() == "boaster" and + entry.killer_team_short == "FNC"] + + boaster_total_op_kills = sum(entry.kills for entry in boaster_op_entries if entry.kills) if boaster_op_entries else 0 + assert boaster_total_op_kills == 0, f"Boaster should have 0 OP kills, got {boaster_total_op_kills}" + + def test_performance_542272_brawk_detailed_stats(self): + """Test specific player performance stats for brawk in game 542272.""" + perf = vlr.series.performance(series_id=542272) + assert isinstance(perf, list) + + # Find the All Maps performance data + all_maps_perf = None + for game in perf: + if game.game_id == "All": + all_maps_perf = game + break + + assert all_maps_perf is not None + + # Find brawk from NRG in player performances + brawk_performance = None + for player in all_maps_perf.player_performances: + if player.name.lower() == "brawk" and player.team_short == "NRG": + brawk_performance = player + break + + assert brawk_performance is not None, "Should find brawk in player performances" + + # Test brawk's specific stats: 12 2Ks, 66 econ, 1 1v1, 4 plants, 5 defuses + assert brawk_performance.multi_2k == 12, f"brawk should have 12 2Ks, got {brawk_performance.multi_2k}" + assert brawk_performance.econ == 66, f"brawk should have 66 econ, got {brawk_performance.econ}" + assert brawk_performance.clutch_1v1 == 1, f"brawk should have 1 1v1 clutch, got {brawk_performance.clutch_1v1}" + assert brawk_performance.plants == 4, f"brawk should have 4 plants, got {brawk_performance.plants}" + assert brawk_performance.defuses == 5, f"brawk should have 5 defuses, got {brawk_performance.defuses}" + + def test_performance_542272_complete_validation(self): + """Complete validation test for game 542272 with all specific requirements.""" + perf = vlr.series.performance(series_id=542272) + assert isinstance(perf, list) + assert len(perf) > 0 + + # Find the All Maps performance data + all_maps_perf = None + for game in perf: + if game.game_id == "All": + all_maps_perf = game + break + + assert all_maps_perf is not None + assert all_maps_perf.kill_matrix is not None + assert all_maps_perf.fkfd_matrix is not None + assert all_maps_perf.op_matrix is not None + assert all_maps_perf.player_performances is not None + + # Validate team assignments + nrg_players = [p for p in all_maps_perf.player_performances if p.team_short == "NRG"] + fnc_players = [p for p in all_maps_perf.player_performances if p.team_short == "FNC"] + + assert len(nrg_players) > 0, "Should have NRG players" + assert len(fnc_players) > 0, "Should have FNC players" + + # Validate specific players exist + brawk = next((p for p in nrg_players if p.name.lower() == "brawk"), None) + mada = next((p for p in nrg_players if p.name.lower() == "mada"), None) + chronicle = next((p for p in fnc_players if p.name.lower() == "chronicle"), None) + boaster = next((p for p in fnc_players if p.name.lower() == "boaster"), None) + + assert brawk is not None, "brawk should be in NRG players" + assert mada is not None, "mada should be in NRG players" + assert chronicle is not None, "chronicle should be in FNC players" + assert boaster is not None, "boaster should be in FNC players" + + # Cross-validate kill matrix with player performances + brawk_chronicle_kills = [e for e in all_maps_perf.kill_matrix + if e.killer_name.lower() == "brawk" and + e.victim_name.lower() == "chronicle"] + assert len(brawk_chronicle_kills) > 0, "Should have brawk vs chronicle kill data" + + print(f"✅ All validations passed for game 542272:") + print(f" - Found {len(nrg_players)} NRG players and {len(fnc_players)} FNC players") + print(f" - brawk: {brawk.multi_2k} 2Ks, {brawk.econ} econ, {brawk.clutch_1v1} 1v1s") + print(f" - Kill matrix has {len(all_maps_perf.kill_matrix)} entries") + print(f" - FKFD matrix has {len(all_maps_perf.fkfd_matrix)} entries") + print(f" - OP matrix has {len(all_maps_perf.op_matrix)} entries") + + def test_performance_542272_skuba_corrode_detailed_stats(self): + """Test specific player performance stats for Skuba on Corrode map in game 542272.""" + perf = vlr.series.performance(series_id=542272) + assert isinstance(perf, list) + assert len(perf) > 0 + + # Find the Corrode map performance data + corrode_perf = None + for game in perf: + if game.map_name and "corrode" in game.map_name.lower(): + corrode_perf = game + break + + assert corrode_perf is not None, "Should find Corrode map performance data" + + # Find Skuba from NRG in Corrode map player performances + skuba_performance = None + for player in corrode_perf.player_performances: + if player.name.lower() == "skuba" and player.team_short == "NRG": + skuba_performance = player + break + + assert skuba_performance is not None, "Should find Skuba in Corrode map player performances" + + # Test Skuba's specific stats: 1 1v2, 1 3K, 42 econ, 0 plants, 1 defuse + assert skuba_performance.clutch_1v2 == 1, f"Skuba should have 1 1v2 clutch, got {skuba_performance.clutch_1v2}" + assert skuba_performance.multi_3k == 1, f"Skuba should have 1 3K, got {skuba_performance.multi_3k}" + assert skuba_performance.econ == 42, f"Skuba should have 42 econ, got {skuba_performance.econ}" + assert skuba_performance.plants == 0, f"Skuba should have 0 plants, got {skuba_performance.plants}" + assert skuba_performance.defuses == 1, f"Skuba should have 1 defuse, got {skuba_performance.defuses}" + + def test_performance_542272_skuba_corrode_detailed_events(self): + """Test specific detailed events for Skuba on Corrode map in game 542272.""" + perf = vlr.series.performance(series_id=542272) + assert isinstance(perf, list) + + # Find the Corrode map performance data + corrode_perf = None + for game in perf: + if game.map_name and "corrode" in game.map_name.lower(): + corrode_perf = game + break + + assert corrode_perf is not None, "Should find Corrode map performance data" + + # Find Skuba from NRG in Corrode map player performances + skuba_performance = None + for player in corrode_perf.player_performances: + if player.name.lower() == "skuba" and player.team_short == "NRG": + skuba_performance = player + break + + assert skuba_performance is not None, "Should find Skuba in Corrode map player performances" + + # Test 1v2 clutch details: Round 9 killing Chronicle and kaajak + assert skuba_performance.clutch_1v2_details is not None, "Skuba should have 1v2 clutch details" + assert len(skuba_performance.clutch_1v2_details) >= 1, "Skuba should have at least 1 1v2 clutch detail" + + clutch_1v2_detail = skuba_performance.clutch_1v2_details[0] + assert clutch_1v2_detail.round_number == 9, f"Skuba's 1v2 clutch should be in round 9, got {clutch_1v2_detail.round_number}" + + # Check that Chronicle and kaajak are in the players killed + players_killed_lower = [p.lower() for p in clutch_1v2_detail.players_killed] + assert "chronicle" in players_killed_lower, "Chronicle should be in Skuba's 1v2 clutch victims" + assert "kaajak" in players_killed_lower, "kaajak should be in Skuba's 1v2 clutch victims" + + # Test 3K details: Round 8 killing kaajak, boaster, and crashies + assert skuba_performance.multi_3k_details is not None, "Skuba should have 3K details" + assert len(skuba_performance.multi_3k_details) >= 1, "Skuba should have at least 1 3K detail" + + multi_3k_detail = skuba_performance.multi_3k_details[0] + assert multi_3k_detail.round_number == 8, f"Skuba's 3K should be in round 8, got {multi_3k_detail.round_number}" + + # Check that kaajak, boaster, and crashies are in the players killed + players_killed_3k_lower = [p.lower() for p in multi_3k_detail.players_killed] + assert "kaajak" in players_killed_3k_lower, "kaajak should be in Skuba's 3K victims" + assert "boaster" in players_killed_3k_lower, "boaster should be in Skuba's 3K victims" + assert "crashies" in players_killed_3k_lower, "crashies should be in Skuba's 3K victims" + + print(f"✅ Skuba Corrode validations passed:") + print(f" - 1v2 clutch in round {clutch_1v2_detail.round_number}: {', '.join(clutch_1v2_detail.players_killed)}") + print(f" - 3K in round {multi_3k_detail.round_number}: {', '.join(multi_3k_detail.players_killed)}") + print(f" - Stats: {skuba_performance.econ} econ, {skuba_performance.plants} plants, {skuba_performance.defuses} defuses")