bulid 63
This commit is contained in:
726
hockey_data/match_parser.py
Normal file
726
hockey_data/match_parser.py
Normal file
@@ -0,0 +1,726 @@
|
||||
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
|
||||
Reference in New Issue
Block a user