"""Validated names and presentation data for both old and new KHL exports. This module is deliberately independent of FastAPI and network clients. Lists and cards use the same names; staff statistics are derived from personal tables, never from the site's navigation, footer, season selector or global club list. """ from __future__ import annotations import re from typing import Any def text(value: Any) -> str: return re.sub(r"\s+", " ", str(value or "")).strip() def valid_name(value: Any, language: str = "ru") -> bool: name = text(value) if name.casefold() in {"имя не загружено", "без имени"}: return False if not 3 <= len(name) <= 100: return False if re.search(r"конференц|дивизион|статистик|хоккейная|дата рождения|гражданство|контракт", name, re.I): return False blocked = {"игрок", "игроки", "тренеры", "судьи", "сезон", "матчи", "новости", "команда", "клуб", "амплуа", "khl", "league", "conference", "statistics", "players", "coaches", "officials", "hockey", "news"} if set(name.casefold().split()) & blocked: return False letters = "А-Яа-яЁё" if language == "ru" else "A-Za-zÀ-ž" return bool(re.fullmatch(rf"[{letters}][{letters}'’−-]*(?:\s+(?:[{letters}][{letters}'’−-]*|[{letters}]\.)){{1,4}}", name)) def surname_first(value: Any) -> str: parts = text(value).split() return " ".join((parts[1], parts[0])) if len(parts) >= 2 else "" def player_name(item: dict[str, Any], language: str = "ru") -> str: identity = item.get("identity" if language == "ru" else "identity_en") or {} profile = item.get("profile") or {} # A list column has a known order: surname, given name, optional patronymic. listed = surname_first(profile.get(f"list_name_{language}")) structured = " ".join(filter(None, (text(identity.get("first_name")), text(identity.get("last_name"))))) candidates = [listed, structured, identity.get("full_name"), profile.get("name" if language == "ru" else "name_en")] if language == "en": candidates.append((item.get("profile_en") or {}).get("name")) for candidate in candidates: if valid_name(candidate, language): return text(candidate) # No invented personal name when the old file contains only menu text. return "Имя не загружено" if language == "ru" else "" def staff_name(item: dict[str, Any], language: str = "ru") -> str: profile = item.get("profile") or {} local = (item.get("languages") or {}).get(language) or {} candidates = [" ".join(filter(None, (text(item.get(f"first_name_{language}")), text(item.get(f"last_name_{language}")))))] for source in (local, profile if language == "ru" else {}): parts = source.get("name_parts") or {} candidates.append(" ".join(filter(None, (text(parts.get("first_name")), text(parts.get("last_name")))))) for candidate in candidates: if valid_name(candidate, language): return candidate raw = [item.get(f"name_{language}"), local.get("name")] if language == "ru": raw += [profile.get("name"), item.get("name")] title = text(profile.get("page_title")) raw.append(re.split(r",\s*(?:тренер|судья|арбитр)\b", title, maxsplit=1, flags=re.I)[0]) else: # EN profile text is normally already in given-name/surname order. candidate = profile.get("name_en") if valid_name(candidate, "en"): return text(candidate) for candidate in raw: if valid_name(candidate, language): return surname_first(candidate) return "Имя не загружено" if language == "ru" else "" def season_name(value: Any) -> str: """Accept consecutive seasons, rejecting copyright 2008–2026 and dates.""" value = text(value) match = re.search(r"(? str: low = text(value).casefold() if "надежд" in low or "hope" in low: return "Кубок Надежды" if "плей" in low or "playoff" in low or "play-off" in low: return "Плей-офф" if "рег" in low or "regular" in low: return "Регулярный чемпионат" return "КХЛ" def number(value: Any) -> int | float | None: value = text(value).replace("−", "-").replace(",", ".").replace("%", "") if not re.fullmatch(r"-?\d+(?:\.\d+)?", value): return None n = float(value) return int(n) if n.is_integer() else n def official_role(value: Any) -> str: low = text(value).casefold() if "линей" in low or low == "лн." or "linesman" in low: return "Линейный судья" if "главн" in low or low == "гл." or low == "referee": return "Главный судья" return "" COACH_KEYS = ("games", "wins", "win_ot", "win_so", "loss_so", "loss_ot", "losses", "points", "points_pct") PENALTY_KEYS = ("minor", "major", "misconduct", "game_misconduct", "match_penalty", "penalty_shots", "team_minutes", "goalie_minutes") def _modern_stats(values, headers, kind): if len(values) != len(headers) or not any(' · Х' in text(h) for h in headers): return None labels = dict(zip(('И', 'В', 'ВО', 'ВБ', 'ПБ', 'ПО', 'П', 'О', '%О'), COACH_KEYS)) if kind == 'coaches' else { 'И': 'games', 'Штр': 'penalty_minutes', 'Штр/И': 'penalty_avg', **dict(zip(('Штр_2', 'Штр_5', 'Штр_10', 'Штр_20', 'Штр_25', 'ШБр', 'ШтрК', 'ШтрВ'), PENALTY_KEYS))} result = {'home': {}, 'away': {}} for label, value in zip(headers, values): parts = text(label).split(' · ') key = labels.get(parts[0]) if key: scope = result.get({'Х': 'home', 'Г': 'away'}.get(parts[-1]), result) scope[key] = number(value) if kind == 'coaches': for scope in (result, result['home'], result['away']): for target, keys in (('wins_total', ('wins', 'win_ot', 'win_so')), ('losses_total', ('losses', 'loss_ot', 'loss_so'))): vals = [scope.get(k) for k in keys] if all(v is not None for v in vals): scope[target] = sum(vals) if scope.get('games') and scope.get('wins_total') is not None: scope['wins_pct'] = round(100 * scope['wins_total'] / scope['games'], 1) return result def _coach_stats(values: list[Any], headers: list[Any]) -> dict[str, Any] | None: # The legacy export preserved the 3 subcolumns (total/home/away), but lost # their parent headers. Only accept the known table and exact row width. if len(values) == 28 and sum(text(x) == "Всего" for x in headers) >= 8: result = {key: number(values[1 + i * 3]) for i, key in enumerate(COACH_KEYS)} result["home"] = {key: number(values[2 + i * 3]) for i, key in enumerate(COACH_KEYS)} result["away"] = {key: number(values[3 + i * 3]) for i, key in enumerate(COACH_KEYS)} elif len(values) == 10 and {"И", "ВО", "ВБ", "%О"}.issubset(set(map(text, headers))): result = {key: number(values[i + 1]) for i, key in enumerate(COACH_KEYS)} else: return None if result.get("games") is None: return None for scope in (result, result.get("home", {}), result.get("away", {})): for target, keys in (("wins_total", ("wins", "win_ot", "win_so")), ("losses_total", ("losses", "loss_ot", "loss_so"))): vals = [scope.get(key) for key in keys] if all(v is not None for v in vals): scope[target] = sum(vals) if scope.get("games") and scope.get("wins_total") is not None: scope["wins_pct"] = round(100 * scope["wins_total"] / scope["games"], 1) return result def _official_stats(values: list[Any], headers: list[Any]) -> dict[str, Any] | None: if len(values) != 26 or "Амп" not in headers or "И" not in headers: return None stats = {"games": number(values[3]), "penalty_minutes": number(values[4]), "penalty_avg": number(values[7])} if stats["games"] is None: return None stats["home"] = {"penalty_minutes": number(values[5]), "penalty_avg": number(values[8])} stats["away"] = {"penalty_minutes": number(values[6]), "penalty_avg": number(values[9])} for i, key in enumerate(PENALTY_KEYS): home, away = number(values[10 + i * 2]), number(values[11 + i * 2]) stats[key] = home + away if home is not None and away is not None else None stats["home"][key], stats["away"][key] = home, away return stats def staff_view(item: dict[str, Any], kind: str) -> dict[str, Any]: profile = item.get("profile") or (item.get("languages") or {}).get("ru") or {} rows, totals, matches, seen = [], [], [], set() unparsed = 0 for table in profile.get("tables") or []: headers = table.get("headers") or [] for raw in table.get("rows") or []: values = raw.get("values") or [] if not values: continue label, section = text(values[0]), text(raw.get("section")) modern = table.get('layout') == 'khl2026' if modern and headers[:2] == ['Дата', 'Команды']: try: from .modern_html import match_date except ImportError: from modern_html import match_date day = match_date(label, raw.get('year', '')) if day and len(values) == len(headers): stats = _modern_stats(values, headers, kind) or {} role_column = next((i for i, h in enumerate(headers) if text(h) in ('Амплуа', 'Амп')), None) role = official_role(values[role_column]) if role_column is not None else '' key = ('match', day, *map(text, values[1:])) if key not in seen: seen.add(key) matches.append({'date': day, 'teams': text(values[1]), 'score': text(values[2]), 'section': section, 'role': role, 'stats': stats, 'penalty_minutes': stats.get('penalty_minutes'), 'home_minutes': stats.get('home', {}).get('penalty_minutes'), 'away_minutes': stats.get('away', {}).get('penalty_minutes')}) continue if kind == "officials" and len(values) == 24 and re.fullmatch(r"\d{4}-\d{2}-\d{2}", label): key = ("match", *map(text, values)) if key not in seen: seen.add(key) matches.append({"date": label, "teams": text(values[1]), "score": text(values[2]), "role": official_role(values[4]), "penalty_minutes": number(values[5]), "home_minutes": number(values[6]), "away_minutes": number(values[7])}) continue season = season_name(section) if kind == "coaches" or modern else season_name(label) is_total = not season and ("суммар" in section.casefold() or label.casefold().startswith(("всего", "регулярный чемпионат:", "плей-офф:", "кубок надежды:"))) if not season and not is_total: continue stats = _modern_stats(values, headers, kind) if modern else (_coach_stats(values, headers) if kind == "coaches" else _official_stats(values, headers)) if stats is None or stats.get('games') is None: unparsed += 1 continue role = official_role(values[2]) if kind == "officials" else "Тренер" record = {"season": season, "stage": stage_name(section if kind == "coaches" and season else label), "team": label if kind == "coaches" and season else "", "role": role, "number": text(values[1]) if kind == "officials" else "", "stats": stats} if modern: record['stage'] = stage_name(label) record['team'] = text(values[headers.index('Клуб')]) if kind == 'coaches' and season and 'Клуб' in headers else '' if is_total: record["label"] = "Всего в КХЛ" if label.casefold().startswith("всего") else stage_name(label) key = (season, record["stage"], record["team"], role, record.get("label", ""), *map(text, values[1:])) if key not in seen: seen.add(key) (totals if is_total else rows).append(record) rows.sort(key=lambda r: (r["season"], r["stage"] == "Плей-офф", r["team"]), reverse=True) fields = profile.get("fields") or {} facts = [] for label, keys in (("Дата рождения", ("Дата рождения", "birthday", "birth_date")), ("Возраст", ("Возраст", "age")), ("Гражданство", ("Гражданство", "country")), ("Клуб", ("Клуб",)), ("Номер", ("Номер", "shirt_number")), ("Место рождения", ("Место рождения", "birth_place"))): value = next((fields[k] for k in keys if fields.get(k)), "") if isinstance(value, (str, int, float)) and len(str(value)) < 130: facts.append({"label": label, "value": value}) roles = sorted({r["role"] for r in rows if r["role"]}) if kind == "officials" and not roles: role = official_role(fields.get("Амплуа") or fields.get("role")) roles = [role] if role else [] return {"rows": rows, "totals": totals, "matches": matches, "facts": facts, "seasons": sorted({r["season"] for r in rows}, reverse=True), "teams": sorted({r["team"] for r in rows if r["team"]}), "roles": roles, "unparsed_rows": unparsed}