Files
hockey_new/hockey_data/match_parser.py
2026-08-19 15:08:39 +03:00

727 lines
24 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.

from __future__ import annotations
import hashlib
import re
import unicodedata
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import Any
from xml.etree import ElementTree as ET
def local_name(tag: str) -> str:
return str(tag or "").split("}")[-1].split(":")[-1]
def normalise_key(value: str) -> str:
return re.sub(r"[^a-z0-9]+", "", local_name(value).lower())
def text_value(node: ET.Element | None) -> str:
if node is None:
return ""
return " ".join(part.strip() for part in node.itertext() if part.strip()).strip()
def first_value(values: dict[str, str], aliases: tuple[str, ...]) -> str:
for alias in aliases:
value = values.get(normalise_key(alias), "").strip()
if value:
return value
return ""
def safe_int(value: Any, default: int = 0) -> int:
match = re.search(r"-?\d+", str(value or ""))
try:
return int(match.group(0)) if match else default
except (TypeError, ValueError):
return default
_RU_MONTHS = {
"январь": 1, "января": 1,
"февраль": 2, "февраля": 2,
"март": 3, "марта": 3,
"апрель": 4, "апреля": 4,
"май": 5, "мая": 5,
"июнь": 6, "июня": 6,
"июль": 7, "июля": 7,
"август": 8, "августа": 8,
"сентябрь": 9, "сентября": 9,
"октябрь": 10, "октября": 10,
"ноябрь": 11, "ноября": 11,
"декабрь": 12, "декабря": 12,
}
_EN_MONTHS = {
"january": 1, "jan": 1,
"february": 2, "feb": 2,
"march": 3, "mar": 3,
"april": 4, "apr": 4,
"may": 5,
"june": 6, "jun": 6,
"july": 7, "jul": 7,
"august": 8, "aug": 8,
"september": 9, "sep": 9, "sept": 9,
"october": 10, "oct": 10,
"november": 11, "nov": 11,
"december": 12, "dec": 12,
}
def parse_date_value(value: str) -> date | None:
# KHL/Stat2TV sometimes uses non-breaking/thin spaces and other Unicode
# punctuation in localized dates. Normalize that input first so values like
# "16 августа 2026, Вс" are parsed consistently on every OS/locale.
value = unicodedata.normalize("NFKC", str(value or ""))
value = value.replace("\u00a0", " ").replace("\u202f", " ").strip()
value = re.sub(r"\s+", " ", value)
if not value or value == "0000-00-00":
return None
iso_match = re.search(r"(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})", value)
if iso_match:
try:
return date(
int(iso_match.group(1)),
int(iso_match.group(2)),
int(iso_match.group(3)),
)
except ValueError:
pass
numeric_match = re.search(r"(\d{1,2})[./-](\d{1,2})[./-](\d{4})", value)
if numeric_match:
try:
return date(
int(numeric_match.group(3)),
int(numeric_match.group(2)),
int(numeric_match.group(1)),
)
except ValueError:
pass
# Stat2TV/KHL detail cards may return a localized human-readable value,
# e.g. "16 августа 2026, Вс 13:00:00". Parse it without relying on
# the PostgreSQL/OS locale so game_date always remains a real DATE.
word_month_match = re.search(
r"\b(\d{1,2})\s+([A-Za-zА-Яа-яЁё]+)\s+(\d{4})\b",
value,
)
if word_month_match:
month_name = word_month_match.group(2).casefold().strip(".,")
month = _RU_MONTHS.get(month_name) or _EN_MONTHS.get(month_name)
if month:
try:
return date(
int(word_month_match.group(3)),
month,
int(word_month_match.group(1)),
)
except ValueError:
pass
english_month_match = re.search(
r"\b([A-Za-z]+)\s+(\d{1,2}),?\s+(\d{4})\b",
value,
)
if english_month_match:
month = _EN_MONTHS.get(english_month_match.group(1).casefold())
if month:
try:
return date(
int(english_month_match.group(3)),
month,
int(english_month_match.group(2)),
)
except ValueError:
pass
for fmt in (
"%Y%m%d",
"%d%m%Y",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%d %H:%M:%S",
):
try:
return datetime.strptime(value[:19], fmt).date()
except ValueError:
continue
return None
def parse_time_value(value: str) -> str:
value = str(value or "").strip()
match = re.search(r"(?:T|\s|^)([0-2]?\d):([0-5]\d)(?::[0-5]\d)?", value)
if not match:
match = re.search(r"\b([0-2]?\d)[.-]([0-5]\d)\b", value)
if not match:
return value if re.fullmatch(r"\d{1,2}:\d{2}", value) else ""
return f"{int(match.group(1)):02d}:{match.group(2)}"
@dataclass(slots=True)
class ParsedGame:
external_id: str
tournament_external_id: str
tournament_type: str = ""
game_number: str = ""
round_number: str = ""
round_name_ru: str = ""
round_name_en: str = ""
day_of_week: str = ""
changes: int = 0
game_date: date | None = None
start_time: str = ""
start_datetime_raw: str = ""
status: str = "scheduled"
approved: bool = False
arena_external_id: str = ""
arena_ru: str = ""
arena_en: str = ""
arena_city_ru: str = ""
arena_city_en: str = ""
home_team_external_id: str = ""
away_team_external_id: str = ""
home_entry_external_id: str = ""
away_entry_external_id: str = ""
home_team_name_ru: str = ""
home_team_name_en: str = ""
home_team_short_ru: str = ""
home_team_short_en: str = ""
away_team_name_ru: str = ""
away_team_name_en: str = ""
away_team_short_ru: str = ""
away_team_short_en: str = ""
home_team_city_ru: str = ""
home_team_city_en: str = ""
away_team_city_ru: str = ""
away_team_city_en: str = ""
home_score: int = 0
away_score: int = 0
score_raw: str = ""
overtime_type: str = ""
period_1_score: str = ""
period_2_score: str = ""
period_3_score: str = ""
period_4_score: str = ""
series_score: str = ""
attendance: int = 0
temporary_value: str = ""
period_scores: str = ""
raw_xml: str = ""
@dataclass(slots=True)
class MatchParseResult:
games: list[ParsedGame] = field(default_factory=list)
root_tag: str = ""
item_tag: str = ""
fields: list[str] = field(default_factory=list)
candidate_nodes: int = 0
confidence: float = 0.0
sample: dict[str, Any] = field(default_factory=dict)
ID_ALIASES = (
"id", "gameId", "game_id", "matchId", "match_id", "eventId",
"event_id", "uid", "gameUid", "game_id_khl",
)
TOURNAMENT_ALIASES = (
"tournamentId", "tournament_id", "competitionId", "championshipId",
"seasonId", "stageId",
)
DATE_ALIASES = (
"date", "gameDate", "game_date", "startDate", "start_date",
"eventDate", "scheduledDate", "datetime", "dateTime",
"startDateTime", "start_datetime", "dt",
)
TIME_ALIASES = (
"time", "gameTime", "startTime", "start_time", "eventTime",
"scheduledTime", "beginTime", "datetime", "dateTime", "startDateTime",
)
STATUS_ALIASES = (
"status", "gameStatus", "state", "phase", "resultType", "finished",
)
NUMBER_ALIASES = (
"number", "gameNumber", "game_number", "matchNumber", "num", "n",
)
ARENA_RU_ALIASES = (
"arena", "arenaName", "arenaRu", "stadium", "venue", "place",
"rink", "location",
)
ARENA_EN_ALIASES = (
"arenaEn", "arenaNameEn", "stadiumEn", "venueEn", "placeEn",
)
HOME_ID_ALIASES = (
"homeTeamId", "home_id", "team1Id", "teamAId", "hostId",
"firstTeamId", "teamHomeId",
)
AWAY_ID_ALIASES = (
"awayTeamId", "away_id", "team2Id", "teamBId", "guestId",
"secondTeamId", "teamAwayId",
)
HOME_RU_ALIASES = (
"homeTeam", "homeTeamName", "homeName", "team1", "team1Name",
"teamA", "host", "hostName", "firstTeam", "teamHome",
)
HOME_EN_ALIASES = (
"homeTeamEn", "homeTeamNameEn", "homeNameEn", "team1En",
"team1NameEn", "teamAEn", "hostEn", "firstTeamEn",
)
AWAY_RU_ALIASES = (
"awayTeam", "awayTeamName", "awayName", "team2", "team2Name",
"teamB", "guest", "guestName", "secondTeam", "teamAway",
)
AWAY_EN_ALIASES = (
"awayTeamEn", "awayTeamNameEn", "awayNameEn", "team2En",
"team2NameEn", "teamBEn", "guestEn", "secondTeamEn",
)
HOME_CITY_RU_ALIASES = ("homeCity", "team1City", "hostCity", "homeTeamCity")
HOME_CITY_EN_ALIASES = ("homeCityEn", "team1CityEn", "hostCityEn")
AWAY_CITY_RU_ALIASES = ("awayCity", "team2City", "guestCity", "awayTeamCity")
AWAY_CITY_EN_ALIASES = ("awayCityEn", "team2CityEn", "guestCityEn")
HOME_SCORE_ALIASES = (
"homeScore", "scoreHome", "score1", "team1Score", "goals1",
"hostScore", "firstScore",
)
AWAY_SCORE_ALIASES = (
"awayScore", "scoreAway", "score2", "team2Score", "goals2",
"guestScore", "secondScore",
)
PERIOD_SCORE_ALIASES = (
"periodScores", "periodScore", "scoreByPeriods", "periods", "sets",
)
def flatten_node(node: ET.Element) -> dict[str, str]:
values: dict[str, str] = {}
for key, value in node.attrib.items():
if value is not None:
values.setdefault(normalise_key(key), str(value).strip())
for child in list(node):
child_key = normalise_key(child.tag)
value = text_value(child)
if value:
values.setdefault(child_key, value)
for key, attr_value in child.attrib.items():
attr_key = normalise_key(f"{local_name(child.tag)}_{key}")
values.setdefault(attr_key, str(attr_value).strip())
values.setdefault(normalise_key(key), str(attr_value).strip())
for grandchild in list(child):
grand_key = normalise_key(
f"{local_name(child.tag)}_{local_name(grandchild.tag)}"
)
grand_value = text_value(grandchild)
if grand_value:
values.setdefault(grand_key, grand_value)
values.setdefault(normalise_key(grandchild.tag), grand_value)
for key, attr_value in grandchild.attrib.items():
values.setdefault(
normalise_key(
f"{local_name(child.tag)}_{local_name(grandchild.tag)}_{key}"
),
str(attr_value).strip(),
)
return values
def nested_team(node: ET.Element, side: str) -> dict[str, str]:
side_names = {
"home": {
"home", "hometeam", "team1", "teama", "host", "firstteam",
},
"away": {
"away", "awayteam", "team2", "teamb", "guest", "secondteam",
},
}[side]
for child in node.iter():
if child is node:
continue
if normalise_key(child.tag) not in side_names:
continue
attrs = {normalise_key(k): str(v).strip() for k, v in child.attrib.items()}
flat = flatten_node(child)
merged = {**flat, **attrs}
return {
"id": first_value(merged, ("id", "teamId", "clubId", "externalId")),
"name_ru": first_value(
merged, ("name", "nameRu", "title", "teamName", "shortName")
) or text_value(child),
"name_en": first_value(
merged, ("nameEn", "titleEn", "teamNameEn", "shortNameEn")
),
"city_ru": first_value(merged, ("city", "cityRu", "town")),
"city_en": first_value(merged, ("cityEn", "townEn")),
"score": first_value(merged, ("score", "goals", "result")),
}
return {}
def looks_like_game_node(node: ET.Element, values: dict[str, str]) -> bool:
tag = normalise_key(node.tag)
if tag in {"game", "match", "event", "fixture", "calendaritem"}:
return True
has_date = bool(first_value(values, DATE_ALIASES))
has_home = bool(
first_value(values, HOME_RU_ALIASES + HOME_ID_ALIASES)
or nested_team(node, "home")
)
has_away = bool(
first_value(values, AWAY_RU_ALIASES + AWAY_ID_ALIASES)
or nested_team(node, "away")
)
return has_date and has_home and has_away
def parse_score_pair(value: str) -> tuple[int, int]:
match = re.fullmatch(r"\s*(\d+)\s*:\s*(\d+)\s*", str(value or ""))
if not match:
return 0, 0
return int(match.group(1)), int(match.group(2))
def truthy(value: Any) -> bool:
return str(value or "").strip().lower() in {
"1", "true", "yes", "on", "approved",
}
def derive_schedule_status(attrs: dict[str, str]) -> str:
approved = truthy(attrs.get("approved"))
score = str(attrs.get("score") or "").strip()
temporary = str(attrs.get("temp") or "").strip()
if approved:
return "finished"
if temporary or re.fullmatch(r"\s*\d+\s*:\s*\d+\s*", score):
return "live"
return "scheduled"
def parsed_schedule_game(
node: ET.Element,
*,
tournament_external_id: str,
tournament_type: str,
) -> ParsedGame | None:
attrs = {str(key): str(value or "") for key, value in node.attrib.items()}
external_id = attrs.get("id", "").strip()
if not external_id:
return None
score_raw = attrs.get("score", "").strip()
home_score, away_score = parse_score_pair(score_raw)
period_values = [
attrs.get("scP1", "").strip(),
attrs.get("scP2", "").strip(),
attrs.get("scP3", "").strip(),
attrs.get("scP4", "").strip(),
]
return ParsedGame(
external_id=external_id,
tournament_external_id=(
attrs.get("tnId", "").strip() or tournament_external_id
),
tournament_type=tournament_type,
game_number=attrs.get("number", "").strip(),
round_number=attrs.get("round", "").strip(),
round_name_ru=attrs.get("roundname", "").strip(),
round_name_en=attrs.get("roundname_en", "").strip(),
day_of_week=attrs.get("dayofweek", "").strip(),
changes=safe_int(attrs.get("changes")),
game_date=parse_date_value(attrs.get("date", "")),
start_time=parse_time_value(attrs.get("time", "")),
start_datetime_raw=(
f"{attrs.get('date', '').strip()} "
f"{attrs.get('time', '').strip()}"
).strip(),
status=derive_schedule_status(attrs),
approved=truthy(attrs.get("approved")),
arena_external_id=attrs.get("arenaid", "").strip(),
arena_ru=attrs.get("arena", "").strip(),
arena_en=attrs.get("arena_en", "").strip(),
arena_city_ru=attrs.get("arena_city", "").strip(),
arena_city_en=attrs.get("arena_city_en", "").strip(),
home_team_external_id=attrs.get("teama", "").strip(),
away_team_external_id=attrs.get("teamb", "").strip(),
home_entry_external_id=attrs.get("homeId", "").strip(),
away_entry_external_id=attrs.get("visitorId", "").strip(),
home_team_name_ru=attrs.get("homeName", "").strip(),
home_team_name_en=attrs.get("homeName_en", "").strip(),
home_team_short_ru=attrs.get("homeName_l3", "").strip(),
home_team_short_en=attrs.get("homeName_l3_en", "").strip(),
away_team_name_ru=attrs.get("visitorName", "").strip(),
away_team_name_en=attrs.get("visitorName_en", "").strip(),
away_team_short_ru=attrs.get("visitorName_l3", "").strip(),
away_team_short_en=attrs.get("visitorName_l3_en", "").strip(),
home_team_city_ru=attrs.get("homeCity", "").strip(),
home_team_city_en=attrs.get("homeCity_en", "").strip(),
away_team_city_ru=attrs.get("visitorCity", "").strip(),
away_team_city_en=attrs.get("visitorCity_en", "").strip(),
home_score=home_score,
away_score=away_score,
score_raw=score_raw,
overtime_type=attrs.get("ots", "").strip(),
period_1_score=period_values[0],
period_2_score=period_values[1],
period_3_score=period_values[2],
period_4_score=period_values[3],
series_score=attrs.get("sscore", "").strip(),
attendance=safe_int(attrs.get("attendance")),
temporary_value=attrs.get("temp", "").strip(),
period_scores="|".join(period_values),
raw_xml=ET.tostring(node, encoding="unicode"),
)
def parse_stat2tv_schedule(
root: ET.Element,
*,
tournament_external_id: str,
) -> MatchParseResult:
result = MatchParseResult(root_tag=local_name(root.tag), item_tag="Game")
tournament_id = str(
root.attrib.get("tournamentId") or tournament_external_id
).strip()
tournament_type = str(
root.attrib.get("tournamentType") or ""
).strip().lower()
field_names: set[str] = set()
for node in root.findall("./Game"):
field_names.update(str(key) for key in node.attrib)
game = parsed_schedule_game(
node,
tournament_external_id=tournament_id,
tournament_type=tournament_type,
)
if game is not None:
result.games.append(game)
result.candidate_nodes = len(result.games)
result.fields = sorted(field_names)
result.confidence = 1.0 if result.games else 0.0
if result.games:
sample = result.games[0]
result.sample = {
"external_id": sample.external_id,
"date": sample.game_date.isoformat() if sample.game_date else None,
"time": sample.start_time,
"home": sample.home_team_name_ru or sample.home_team_name_en,
"away": sample.away_team_name_ru or sample.away_team_name_en,
"status": sample.status,
"approved": sample.approved,
"score": sample.score_raw,
"series_score": sample.series_score,
"tournament_type": sample.tournament_type,
}
return result
def parse_stat2tv_game_by_id(
content: bytes | str,
*,
tournament_external_id: str,
game_external_id: str,
) -> ParsedGame | None:
"""Parse only one game from a Stat2TV schedule response."""
raw = content.encode("utf-8") if isinstance(content, str) else content
root = ET.fromstring(raw)
if normalise_key(root.tag) != "schedule":
result = parse_games_xml(
raw,
tournament_external_id=tournament_external_id,
)
return next(
(
game for game in result.games
if str(game.external_id) == str(game_external_id)
),
None,
)
tournament_id = str(
root.attrib.get("tournamentId") or tournament_external_id
).strip()
tournament_type = str(
root.attrib.get("tournamentType") or ""
).strip().lower()
node = root.find(f"./Game[@id='{str(game_external_id)}']")
if node is None:
return None
return parsed_schedule_game(
node,
tournament_external_id=tournament_id,
tournament_type=tournament_type,
)
def parse_games_xml(
content: bytes | str,
*,
tournament_external_id: str,
) -> MatchParseResult:
raw = content.encode("utf-8") if isinstance(content, str) else content
root = ET.fromstring(raw)
if (
normalise_key(root.tag) == "schedule"
and root.findall("./Game")
):
return parse_stat2tv_schedule(
root,
tournament_external_id=tournament_external_id,
)
result = MatchParseResult(root_tag=local_name(root.tag))
nodes: list[tuple[ET.Element, dict[str, str]]] = []
for node in root.iter():
if node is root:
continue
values = flatten_node(node)
if looks_like_game_node(node, values):
nodes.append((node, values))
# Avoid parsing nested team/event nodes as games when their parent is already a game.
node_ids = {id(node) for node, _ in nodes}
filtered_nodes: list[tuple[ET.Element, dict[str, str]]] = []
for node, values in nodes:
parent_is_candidate = False
for parent, _ in nodes:
if parent is node:
continue
if any(child is node for child in parent.iter()):
parent_is_candidate = True
break
if not parent_is_candidate:
filtered_nodes.append((node, values))
if not filtered_nodes:
filtered_nodes = nodes
result.candidate_nodes = len(filtered_nodes)
seen: set[str] = set()
field_names: set[str] = set()
for index, (node, values) in enumerate(filtered_nodes, start=1):
field_names.update(values.keys())
home_nested = nested_team(node, "home")
away_nested = nested_team(node, "away")
raw_datetime = first_value(values, DATE_ALIASES)
raw_time = first_value(values, TIME_ALIASES)
game_date = parse_date_value(raw_datetime)
start_time = parse_time_value(raw_time or raw_datetime)
external_id = first_value(values, ID_ALIASES)
if not external_id:
material = (
f"{tournament_external_id}|{raw_datetime}|"
f"{first_value(values, HOME_RU_ALIASES)}|"
f"{first_value(values, AWAY_RU_ALIASES)}|{index}"
)
external_id = "auto-" + hashlib.sha1(
material.encode("utf-8", errors="ignore")
).hexdigest()[:20]
if external_id in seen:
continue
seen.add(external_id)
game = ParsedGame(
external_id=external_id,
tournament_external_id=(
first_value(values, TOURNAMENT_ALIASES)
or tournament_external_id
),
game_number=first_value(values, NUMBER_ALIASES),
game_date=game_date,
start_time=start_time,
start_datetime_raw=raw_datetime or raw_time,
status=first_value(values, STATUS_ALIASES),
arena_ru=first_value(values, ARENA_RU_ALIASES),
arena_en=first_value(values, ARENA_EN_ALIASES),
home_team_external_id=(
first_value(values, HOME_ID_ALIASES)
or home_nested.get("id", "")
),
away_team_external_id=(
first_value(values, AWAY_ID_ALIASES)
or away_nested.get("id", "")
),
home_team_name_ru=(
first_value(values, HOME_RU_ALIASES)
or home_nested.get("name_ru", "")
),
home_team_name_en=(
first_value(values, HOME_EN_ALIASES)
or home_nested.get("name_en", "")
),
away_team_name_ru=(
first_value(values, AWAY_RU_ALIASES)
or away_nested.get("name_ru", "")
),
away_team_name_en=(
first_value(values, AWAY_EN_ALIASES)
or away_nested.get("name_en", "")
),
home_team_city_ru=(
first_value(values, HOME_CITY_RU_ALIASES)
or home_nested.get("city_ru", "")
),
home_team_city_en=(
first_value(values, HOME_CITY_EN_ALIASES)
or home_nested.get("city_en", "")
),
away_team_city_ru=(
first_value(values, AWAY_CITY_RU_ALIASES)
or away_nested.get("city_ru", "")
),
away_team_city_en=(
first_value(values, AWAY_CITY_EN_ALIASES)
or away_nested.get("city_en", "")
),
home_score=safe_int(
first_value(values, HOME_SCORE_ALIASES)
or home_nested.get("score", "")
),
away_score=safe_int(
first_value(values, AWAY_SCORE_ALIASES)
or away_nested.get("score", "")
),
period_scores=first_value(values, PERIOD_SCORE_ALIASES),
raw_xml=ET.tostring(node, encoding="unicode"),
)
result.games.append(game)
if result.games:
result.item_tag = local_name(filtered_nodes[0][0].tag)
complete = sum(
1 for game in result.games
if game.game_date and game.home_team_name_ru and game.away_team_name_ru
)
result.confidence = round(complete / max(1, len(result.games)), 3)
sample_game = result.games[0]
result.sample = {
"external_id": sample_game.external_id,
"date": sample_game.game_date.isoformat() if sample_game.game_date else None,
"time": sample_game.start_time,
"home": sample_game.home_team_name_ru or sample_game.home_team_name_en,
"away": sample_game.away_team_name_ru or sample_game.away_team_name_en,
"status": sample_game.status,
}
result.fields = sorted(field_names)
return result