from __future__ import annotations import datetime import difflib import html import json import re import threading import time from typing import Any from urllib.error import HTTPError, URLError from urllib.parse import quote from urllib.request import ProxyHandler, Request, build_opener from zoneinfo import ZoneInfo import config dayStrings = [ "Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье", ] MOSCOW = ZoneInfo("Europe/Moscow") _CACHE_TTL = 300.0 _REQUEST_TIMEOUT = 10.0 _FAILURE_RETRY_DELAY = 30.0 _REQUEST_ATTEMPTS = 2 _cache: dict[str, tuple[float, list[dict[str, Any]]]] = {} _failures: dict[str, tuple[float, str]] = {} _cache_lock = threading.RLock() _direct_opener = build_opener(ProxyHandler({})) class ScheduleAPIError(RuntimeError): pass def _api_url(path: str) -> str: return f"{config.SCHEDULE_BASE_URL.rstrip('/')}/{path.lstrip('/')}" def _load_result(path: str) -> list[dict[str, Any]]: now = time.monotonic() with _cache_lock: cached = _cache.get(path) if cached and now - cached[0] < _CACHE_TTL: return cached[1] failure = _failures.get(path) if failure and now < failure[0]: if cached: return cached[1] raise ScheduleAPIError(failure[1]) url = _api_url(path) request = Request(url, headers={"Accept": "application/json"}) error: Exception | None = None payload: Any = None for _ in range(_REQUEST_ATTEMPTS): try: with _direct_opener.open(request, timeout=_REQUEST_TIMEOUT) as response: payload = json.load(response) error = None break except (HTTPError, URLError, TimeoutError, OSError, ValueError) as attempt_error: error = attempt_error if error is not None: message = f"Не удалось получить {url}: {error}" with _cache_lock: _failures[path] = (now + _FAILURE_RETRY_DELAY, message) if cached: return cached[1] raise ScheduleAPIError(message) from error if ( not isinstance(payload, dict) or payload.get("ok") is not True or not isinstance(payload.get("result"), list) ): if cached: return cached[1] raise ScheduleAPIError(f"Сервер вернул некорректный ответ для {path}") result = payload["result"] with _cache_lock: _cache[path] = (now, result) _failures.pop(path, None) return result def clear_schedule_cache() -> None: with _cache_lock: _cache.clear() _failures.clear() def get_main_groups() -> list[dict[str, Any]]: groups = _load_result("groups") if not groups: raise ScheduleAPIError("Сервер расписания пока не содержит групп") return sorted( (group for group in groups if group.get("parent_group_id") is None), key=lambda group: str(group.get("name", "")).casefold(), ) def _compact_group_name(value: str) -> str: return re.sub(r"[^0-9a-zа-яё]", "", value.casefold()) def find_main_groups(query: str, limit: int = 8) -> list[str]: query = query.strip() if not query: return [] groups = [str(group["name"]) for group in get_main_groups()] compact_query = _compact_group_name(query) exact = [name for name in groups if _compact_group_name(name) == compact_query] if exact: return exact[:1] contains = [name for name in groups if compact_query in _compact_group_name(name)] if contains: return contains[:limit] scored = sorted( ( difflib.SequenceMatcher(None, compact_query, _compact_group_name(name)).ratio(), name, ) for name in groups ) return [name for score, name in reversed(scored) if score >= 0.45][:limit] def group_exists(group_name: str) -> bool: target = _compact_group_name(group_name) return any( _compact_group_name(str(group["name"])) == target for group in get_main_groups() ) def _minutes_to_time(value: int | None) -> list[int]: if value is None: return [0, 0] return [value // 60, value % 60] class Lesson: def __init__(self, data: dict[str, Any], subgroup_names: list[str]) -> None: self.start = _minutes_to_time(data.get("time_start")) self.end = _minutes_to_time(data.get("time_end")) self.strTime = f"{self.start[0]:02d}:{self.start[1]:02d} - {self.end[0]:02d}:{self.end[1]:02d}" self.name = str(data.get("name") or "Без названия") self.type = str(data.get("type") or "пара") self.teacher_ids = [str(value) for value in data.get("teacher_ids", [])] self.room_ids = [str(value) for value in data.get("room_ids", [])] self.subgroup_names = subgroup_names self.info = self.teacher_ids + [f"ауд. {room}" for room in self.room_ids] self.is_odd_week = bool(data.get("is_odd_week")) self.is_even_week = bool(data.get("is_even_week")) def text(self, isToday: bool = False) -> str: now = datetime.datetime.now(MOSCOW) lesson_start = now.replace( hour=self.start[0], minute=self.start[1], second=0, microsecond=0 ) lesson_end = now.replace( hour=self.end[0], minute=self.end[1], second=0, microsecond=0 ) lesson_type = html.escape(self.type) date_text = f"{lesson_type.capitalize()} в {self.start[0]}:{self.start[1]:02d}" if isToday and lesson_start > now: minutes = int((lesson_start - now).total_seconds() // 60) if minutes < 30: date_text = f"{lesson_type.capitalize()} начнется через {max(minutes, 1)} мин." elif isToday and lesson_start <= now < lesson_end: minutes = int((lesson_end - now).total_seconds() // 60) date_text = f"Сейчас идет {lesson_type}, закончится через {max(minutes, 1)} мин." details: list[str] = [] details.extend(html.escape(teacher) for teacher in self.teacher_ids) details.extend( f'ауд. {html.escape(room)}' for room in self.room_ids ) if self.subgroup_names: details.append(html.escape(", ".join(self.subgroup_names))) info = ", ".join(details) if details else "Дополнительная информация отсутствует" return ( f"
{html.escape(self.name)}\n" f"{date_text}\n{info}
" ) class Schedule: def __init__( self, lessons: list[dict[str, Any]], group_name: str, groups: list[dict[str, Any]], now: datetime.datetime | None = None, ) -> None: current = now or datetime.datetime.now(MOSCOW) self.weekNumber = current.isocalendar().week self.weekDay = current.weekday() base_group = next( ( group for group in groups if _compact_group_name(str(group.get("name", ""))) == _compact_group_name(group_name) and group.get("parent_group_id") is None ), None, ) if base_group is None: raise ValueError(f"Группа {group_name!r} не найдена") base_id = str(base_group["id"]) child_names = { str(group["id"]): str(group["name"]) for group in groups if group.get("parent_group_id") == base_id } target_ids = {base_id, *child_names} self.days: list[list[Lesson]] = [[] for _ in range(5)] for raw_lesson in lessons: weekday = raw_lesson.get("weekday") if not isinstance(weekday, int) or weekday not in range(1, 6): continue matched_ids = target_ids.intersection(map(str, raw_lesson.get("group_ids", []))) if not matched_ids: continue subgroup_names = [ child_names[group_id] for group_id in matched_ids if group_id in child_names ] self.days[weekday - 1].append(Lesson(raw_lesson, sorted(subgroup_names))) for day in self.days: day.sort(key=lambda lesson: (lesson.start, lesson.name.casefold())) def today(self) -> list[Lesson]: if self.weekDay not in range(5): return [] even_week = self.weekNumber % 2 == 0 return [ lesson for lesson in self.days[self.weekDay] if (lesson.is_even_week if even_week else lesson.is_odd_week) ] def get_group_schedule(group_name: str) -> Schedule: groups = _load_result("groups") if not groups: raise ScheduleAPIError("Сервер расписания пока не содержит групп") lessons = _load_result("schedule/default") return Schedule(lessons, group_name, groups) def generateDaySchedule( schedule: Schedule, showOddWeekText: bool = True, isToday: bool = False ) -> str: even_week = schedule.weekNumber % 2 == 0 weekday = schedule.weekDay day_name = dayStrings[weekday] header = ( f'{day_name}, {"четная" if even_week else "нечетная"} неделя\n' if showOddWeekText else f"{day_name}\n" ) lessons = schedule.today() if not lessons: return header + "Пар нет 🥰" result = header + "\n".join(lesson.text(isToday) for lesson in lessons) last_end = max(lesson.end for lesson in lessons) return result + f"\nПары закончатся в {last_end[0]}:{last_end[1]:02d}"