727 lines
24 KiB
Python
727 lines
24 KiB
Python
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
|