Files
hockey_new/khl_site/person_data.py

272 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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"(?<!\d)((?:19|20)\d{2})\s*[/–—-]\s*((?:19|20)?\d{2})(?!\d)", value)
if match:
left, right = match.groups()
start = int(left)
end = int(right) if len(right) == 4 else (start // 100) * 100 + int(right)
if end < start and len(right) == 2:
end += 100
else:
match = re.search(r"(?<![\d/.-])(\d{2})\s*[/–—-]\s*(\d{2})(?![\d/.-])", value)
if not match:
return ""
start, end = (2000 + int(x) for x in match.groups())
return f"{start}/{end}" if 2008 <= start <= 2099 and end == start + 1 else ""
def stage_name(value: Any) -> 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}