Source code for vlrdevapi.series.matches

"""Series matches functionality."""

from __future__ import annotations

from bs4 import BeautifulSoup
from bs4.element import Tag

from .models import MapPlayers, PlayerStats, MapTeamScore, RoundResult
from ._parser import _WHITESPACE_RE, _MAP_NUMBER_RE
from .info import info
from ..config import get_config
from ..countries import COUNTRY_MAP
from ..fetcher import fetch_html
from ..exceptions import NetworkError
from ..utils import extract_text, parse_int, extract_id_from_url

_config = get_config()


def _match_team_to_meta(
    game_name: str,
    info_teams: list,
    name_lookup: dict[str, dict],
    short_lookup: dict[str, dict],
    position: int,
    player_table_shorts: set[str] | None = None,
) -> tuple[int | None, str | None]:
    """
    Match game header team name to team metadata using multiple strategies.
    
    Args:
        game_name: Team name from game header
        info_teams: List of TeamInfo from series.info()
        name_lookup: Lookup dict by canonical name
        short_lookup: Lookup dict by short name (uppercase)
        position: Position in game header (0 or 1)
        player_table_shorts: Set of team short names extracted from player table
    
    Returns:
        Tuple of (team_id, short_name)
    """
    if not game_name:
        return None, None
    
    def canonical(value: str | None) -> str | None:
        if not value:
            return None
        return _WHITESPACE_RE.sub(" ", value).strip().lower()
    
    # Strategy 1: Exact canonical name match
    canon = canonical(game_name)
    if canon and canon in name_lookup:
        meta = name_lookup[canon]
        return meta.get("id"), meta.get("short")
    
    # Strategy 2: Short name match (game header might show short)
    game_upper = game_name.upper()
    if game_upper in short_lookup:
        meta = short_lookup[game_upper]
        return meta.get("id"), meta.get("short")
    
    # Strategy 3: Fuzzy match - check if names/shorts contain each other
    name_clean = game_name.lower().replace(" ", "").replace("-", "").replace("_", "")
    
    # 3a: Check against short names
    for short_key, meta in short_lookup.items():
        short_clean = short_key.lower().replace(" ", "").replace("-", "")
        if short_clean and len(short_clean) >= 2:
            if short_clean in name_clean or name_clean in short_clean:
                return meta.get("id"), meta.get("short")
    
    # 3b: Check against info team names (handles sponsor prefixes)
    for team_info in info_teams:
        if team_info.name:
            info_name_clean = team_info.name.lower().replace(" ", "").replace("-", "").replace("_", "")
            if info_name_clean and len(info_name_clean) >= 2:
                if name_clean in info_name_clean or info_name_clean in name_clean:
                    return team_info.id, team_info.short
    
    # Strategy 3.5: Player table short name match
    if player_table_shorts:
        for short in player_table_shorts:
            if short in short_lookup:
                meta = short_lookup[short]
                return meta.get("id"), meta.get("short")
    
    # Strategy 4: Position-based fallback
    # Teams should be in the same order in both info() and game header
    if position < len(info_teams):
        team_info = info_teams[position]
        return team_info.id, team_info.short
    
    return None, None


[docs] def matches(series_id: int, limit: int | None = None, timeout: float | None = None) -> list[MapPlayers]: """ Get detailed match 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 statistics with player data Example: >>> import vlrdevapi as vlr >>> maps = vlr.series.matches(series_id=12345, limit=3) >>> for map_data in maps: ... print(f"Map: {map_data.map_name}") ... for player in map_data.players: ... print(f" {player.name}: {player.acs} ACS") """ url = f"{_config.vlr_base}/{series_id}" 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 [] # Build game_id -> map name from tabs game_name_map: dict[int, 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 [] if any("vm-stats-game" in c for c in nav_classes): continue gid_val = nav.get("data-game-id") gid = gid_val if isinstance(gid_val, str) else None if not gid or not gid.isdigit(): continue txt = nav.get_text(" ", strip=True) if not txt: continue name = _MAP_NUMBER_RE.sub("", txt).strip() game_name_map[int(gid)] = name def canonical(value: str | None) -> str | None: if not value: return None return _WHITESPACE_RE.sub(" ", value).strip().lower() # 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_short_to_meta: dict[str, dict[str, str | int | None]] = {} info_teams_list: list = list(series_details.teams) if series_details else [] 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 if team_info.short: team_short_to_id[team_info.short.upper()] = team_info.id team_short_to_meta[team_info.short.upper()] = team_meta_rec # 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[MapPlayers] = [] 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 = "All" 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 teams from header teams_tuple: tuple[MapTeamScore, MapTeamScore] | None = None header = game.select_one(".vm-stats-game-header") if header: team_divs = header.select(".team") if len(team_divs) >= 2: # Team 1 t1_name_el = team_divs[0].select_one(".team-name") t1_name = extract_text(t1_name_el) if t1_name_el else None t1_score_el = team_divs[0].select_one(".score") t1_score = parse_int(extract_text(t1_score_el)) if t1_score_el else None classes_val = t1_score_el.get("class") if t1_score_el else None score_classes1: list[str] = [str(c) for c in classes_val] if isinstance(classes_val, (list, tuple)) else [] t1_is_winner = "mod-win" in score_classes1 if t1_score_el else False # Parse attacker/defender rounds for team 1 t1_ct = team_divs[0].select_one(".mod-ct") t1_t = team_divs[0].select_one(".mod-t") t1_ct_rounds = parse_int(extract_text(t1_ct)) if t1_ct else None t1_t_rounds = parse_int(extract_text(t1_t)) if t1_t else None # Team 2 t2_name_el = team_divs[1].select_one(".team-name") t2_name = extract_text(t2_name_el) if t2_name_el else None t2_score_el = team_divs[1].select_one(".score") t2_score = parse_int(extract_text(t2_score_el)) if t2_score_el else None classes_val2 = t2_score_el.get("class") if t2_score_el else None score_classes2: list[str] = [str(c) for c in classes_val2] if isinstance(classes_val2, (list, tuple)) else [] t2_is_winner = "mod-win" in score_classes2 if t2_score_el else False # Parse attacker/defender rounds for team 2 t2_ct = team_divs[1].select_one(".mod-ct") t2_t = team_divs[1].select_one(".mod-t") t2_ct_rounds = parse_int(extract_text(t2_ct)) if t2_ct else None t2_t_rounds = parse_int(extract_text(t2_t)) if t2_t else None if t1_name and t2_name: # Extract team short names from player tables for additional matching player_table_shorts: set[str] = set() for table in game.select("table.wf-table-inset"): for short_el in table.select(".mod-player .ge-text-light"): short = extract_text(short_el) if short: player_table_shorts.add(short.upper()) # Use multi-strategy matching for team metadata t1_id_val, t1_short_val = _match_team_to_meta( t1_name, info_teams_list, team_meta_lookup, team_short_to_meta, 0, player_table_shorts ) t2_id_val, t2_short_val = _match_team_to_meta( t2_name, info_teams_list, team_meta_lookup, team_short_to_meta, 1, player_table_shorts ) teams_tuple = ( MapTeamScore( id=t1_id_val if isinstance(t1_id_val, int) else None, name=t1_name, short=t1_short_val if isinstance(t1_short_val, str) else None, score=t1_score, attacker_rounds=t1_t_rounds, defender_rounds=t1_ct_rounds, is_winner=t1_is_winner, ), MapTeamScore( id=t2_id_val if isinstance(t2_id_val, int) else None, name=t2_name, short=t2_short_val if isinstance(t2_short_val, str) else None, score=t2_score, attacker_rounds=t2_t_rounds, defender_rounds=t2_ct_rounds, is_winner=t2_is_winner, ), ) # Parse rounds rounds_list: list[RoundResult] = [] rounds_container = game.select_one(".vlr-rounds") if rounds_container: round_rows = rounds_container.select(".vlr-rounds-row") # Determine top/bottom team order from the rounds legend round_team_names: list[str] = [] if round_rows: header_col = round_rows[0].select_one(".vlr-rounds-row-col") if header_col: round_team_names = [extract_text(team_el) for team_el in header_col.select(".team")] # Flatten all round columns across rows, skipping headers/spacing flat_columns: list[Tag] = [] for row in round_rows: for col in row.select(".vlr-rounds-row-col"): if col.select_one(".team"): continue classes_val = col.get("class") col_classes: list[str] = [str(c) for c in classes_val] if isinstance(classes_val, (list, tuple)) else [] if "mod-spacing" in col_classes: continue flat_columns.append(col) prev_score: tuple[int, int] | None = None final_score_tuple: tuple[int, int] | None = None if teams_tuple and all(ts.score is not None for ts in teams_tuple): final_score_tuple = (teams_tuple[0].score or 0, teams_tuple[1].score or 0) for col in flat_columns: rnd_num_el = col.select_one(".rnd-num") if not rnd_num_el: continue rnd_num = parse_int(extract_text(rnd_num_el)) if rnd_num is None: continue title_val = col.get("title") title = (title_val if isinstance(title_val, str) else "").strip() if not title and not col.select_one(".rnd-sq.mod-win"): # No data beyond this point break score_tuple: tuple[int, int] | None = None if "-" in title: parts = title.split("-") if len(parts) == 2: s1 = parse_int(parts[0].strip()) s2 = parse_int(parts[1].strip()) if s1 is not None and s2 is not None: score_tuple = (s1, s2) # Determine winning square and method winner_sq = col.select_one(".rnd-sq.mod-win") winner_side = None method = None if winner_sq: classes_val = winner_sq.get("class") win_classes: list[str] = [str(c) for c in classes_val] if isinstance(classes_val, (list, tuple)) else [] if "mod-t" in win_classes: winner_side = "Attacker" elif "mod-ct" in win_classes: winner_side = "Defender" method_img = winner_sq.select_one("img") if method_img: src_val = method_img.get("src") src = (src_val if isinstance(src_val, str) else "").lower() if "elim" in src: method = "Elimination" elif "defuse" in src: method = "SpikeDefused" elif "boom" in src or "explosion" in src: method = "SpikeExplosion" elif "time" in src: method = "TimeRunOut" winner_idx: int | None = None if score_tuple is not None: if prev_score is None: winner_idx = 0 if score_tuple[0] > score_tuple[1] else 1 if score_tuple[1] > score_tuple[0] else None else: if score_tuple[0] > prev_score[0]: winner_idx = 0 elif score_tuple[1] > prev_score[1]: winner_idx = 1 prev_score = score_tuple winner_team_id = None winner_team_short = None winner_team_name = None if winner_idx is not None and teams_tuple and 0 <= winner_idx < len(teams_tuple): team_score = teams_tuple[winner_idx] winner_team_id = team_score.id winner_team_short = team_score.short winner_team_name = team_score.name elif winner_idx is not None and round_team_names: team_name = round_team_names[winner_idx] if winner_idx < len(round_team_names) else None if team_name: canon_name = canonical(team_name) winner_meta: dict[str, str | int | None] | None = team_meta_lookup.get(canon_name) if canon_name else None if winner_meta: _id = winner_meta.get("id") _short = winner_meta.get("short") _name = winner_meta.get("name") winner_team_id = _id if isinstance(_id, int) else None winner_team_short = _short if isinstance(_short, str) else None winner_team_name = _name if isinstance(_name, str) else None else: winner_team_name = team_name rounds_list.append(RoundResult( number=rnd_num, winner_side=winner_side, method=method, score=score_tuple, winner_team_id=winner_team_id, winner_team_short=winner_team_short, winner_team_name=winner_team_name, )) if final_score_tuple and score_tuple == final_score_tuple: break # Helpers for player parsing def extract_mod_both(cell: Tag | None) -> str | None: if not cell: return None # Prefer spans containing mod-both for selector in [".side.mod-both", ".side.mod-side.mod-both", ".mod-both"]: el = cell.select_one(selector) if el: return extract_text(el) for el in cell.select("span"): classes_val = el.get("class") classes: list[str] = list(classes_val) if isinstance(classes_val, (list, tuple)) else [] if classes and any("mod-both" in cls for cls in classes): return extract_text(el) return extract_text(cell) def parse_numeric(text: str | None) -> float | None: if not text: return None cleaned = text.strip().replace(",", "") if not cleaned: return None sign = 1 if cleaned.startswith("+"): cleaned = cleaned[1:] elif cleaned.startswith("-"): sign = -1 cleaned = cleaned[1:] percent = cleaned.endswith("%") if percent: cleaned = cleaned[:-1] cleaned = cleaned.strip() if not cleaned: return None try: value = float(cleaned) except ValueError: return None return sign * value # Parse players from both team tables players: list[PlayerStats] = [] tables = game.select("table.wf-table-inset") team_scores = list(teams_tuple) if teams_tuple else [] for table_idx, table in enumerate(tables): tbody = table.select_one("tbody") if not tbody: continue team_score = team_scores[table_idx] if table_idx < len(team_scores) else None team_meta: dict[str, str | int | None] | None = None if team_score: canon_score_name = canonical(team_score.name) team_meta = team_meta_lookup.get(canon_score_name) if canon_score_name else None short_source = team_meta.get("short") if team_meta else (team_score.short if team_score else None) inferred_team_short = short_source if isinstance(short_source, str) else None inferred_team_id_val = team_meta.get("id") if team_meta else (team_score.id if team_score else None) inferred_team_id = inferred_team_id_val if isinstance(inferred_team_id_val, int) else None for row in tbody.select("tr"): player_cell = row.select_one(".mod-player") if not player_cell: continue player_link = player_cell.select_one("a[href*='/player/']") if not player_link: continue href_val = player_link.get("href") href = href_val if isinstance(href_val, str) else None player_id = extract_id_from_url(href, "player") name_el = player_link.select_one(".text-of") name = extract_text(name_el) if name_el else None if not name: continue team_short_el = player_link.select_one(".ge-text-light") player_team_short = extract_text(team_short_el) if team_short_el else inferred_team_short if player_team_short: player_team_short = player_team_short.strip().upper() team_id = None if player_team_short: team_id = team_short_to_id.get(player_team_short.upper(), inferred_team_id) elif inferred_team_id is not None: team_id = inferred_team_id # Country flag = player_cell.select_one(".flag") country = None if flag: classes_val = flag.get("class") player_flag_classes: list[str] = [str(c) for c in classes_val] if isinstance(classes_val, (list, tuple)) else [] for cls in player_flag_classes: if cls.startswith("mod-") and cls != "mod-dark": country_code = cls.removeprefix("mod-") country = COUNTRY_MAP.get(country_code.upper(), country_code.upper()) break # Agents agents: list[str] = [] agents_cell = row.select_one(".mod-agents") if agents_cell: for img in agents_cell.select("img"): title_val = img.get("title") alt_val = img.get("alt") agent_name = title_val if isinstance(title_val, str) else (alt_val if isinstance(alt_val, str) else "") if agent_name: agents.append(agent_name) # Stats stat_cells = row.select(".mod-stat") values: list[float | None] = [parse_numeric(extract_mod_both(cell)) for cell in stat_cells] def as_int(idx: int) -> int | None: if idx >= len(values) or values[idx] is None: return None val = values[idx] return int(val) if val is not None else None def as_float(idx: int) -> float | None: if idx >= len(values) or values[idx] is None: return None return values[idx] r_float = as_float(0) acs_int = as_int(1) k_int = as_int(2) d_int = as_int(3) a_int = as_int(4) kd_diff_int = as_int(5) kast_float = as_float(6) adr_float = as_float(7) hs_pct_float = as_float(8) fk_int = as_int(9) fd_int = as_int(10) fk_diff_int = as_int(11) players.append(PlayerStats( country=country, name=name, team_short=player_team_short, team_id=team_id, player_id=player_id, agents=agents, r=r_float, acs=acs_int, k=k_int, d=d_int, a=a_int, kd_diff=kd_diff_int, kast=kast_float, adr=adr_float, hs_pct=hs_pct_float, fk=fk_int, fd=fd_int, fk_diff=fk_diff_int, )) result.append(MapPlayers( game_id=gid, map_name=map_name, players=players, teams=teams_tuple, rounds=rounds_list if rounds_list else None, )) return result