import json import os import threading import pika import requests import time import traceback from datetime import datetime from fastapi import FastAPI import uvicorn import consts import httpserver from utils import * from consts import * app = FastAPI(title="VSTU Compatibility API") app.include_router(httpserver.router) def merge_week_keys_metadata(sheets): merged = {} for sheet_data in sheets.values(): wkm = sheet_data.get("week_keys_metadata", {}) for day, months in wkm.items(): if day not in merged: merged[day] = {} for month, days in months.items(): if month not in merged[day]: merged[day][month] = [] try: for d in days: if d not in merged[day][month]: merged[day][month].append(d) except Exception as e: traceback.print_exception(e) print(f"sheet_data.reader_info={sheet_data['reader_info']}; week_keys_metadata={wkm}") return merged def get_preferer_facultet(facultets_data: dict, excel_url: str, skip_for=None, ): if skip_for is None: skip_for = [] for _key, _value in facultets_data.items(): if _key.startswith("_"): continue if _key in skip_for: continue short_names = _value.get("short_names", None) if short_names is None: continue for name in short_names: if name.lower() in excel_url.lower(): return _key def get_abbrev_for_facultet(facultets_data: dict, facultet_id: str, fallback_not_found="?", fallback_error="?", fallback_no_short_name="?"): if (facultet_id == 'mag'): return "МАГ" if (facultet_id == 'asp'): return "АСП" for _key, _value in facultets_data.items(): if _key != facultet_id: continue short_names = _value.get("short_names", None) if short_names is None: return fallback_no_short_name try: return short_names[0] except Exception as e: traceback.print_exception(e) return fallback_error return fallback_not_found def get_slot_key_for_event(event: dict): raw = sorted(event.get("raw", [])) pairs = event.get("pairs", []) times = event.get("times", []) weekday = event.get("weekday", None) weeknum = event.get("weeknum", None) w = "" if weeknum is not None: w += "_WN" + str(weeknum) if weekday is not None: w += "_WD" + str(weekday) r = ('_'.join(pairs)) + ("_".join(times)) if len(r) > 0: r = "_" + r return get_raw_hash(raw) + w + r def ask_gemini(unknown_raws, cache): if not GEMINI_API_KEY: log("[!] GEMINI_API_KEY не задан! ИИ-парсинг пропущен.") return {} proxies = {"http": PROXY_URL, "https": PROXY_URL} if PROXY_URL else {} results = {} items = list(unknown_raws.items()) batch_size = 40 for i in range(0, len(items), batch_size): batch = dict(items[i:i+batch_size]) log(f"[*] Отправка батча в Gemini ({i+1}-{min(i+batch_size, len(items))} из {len(items)})...") prompt = f""" Ты парсер академического расписания. Я даю тебе JSON, где ключи - хэши, а значения - массив сырых строк расписания. Твоя задача вернуть JSON, где ключи - те же хэши, а значения - объекты с ключами: - "subject": строка, название предмета (или "Не указан") // Нормализуй subject ВЕРХ. РЕГИСТРОМ. и "С К Л Е И В А Й В О Т Т А К И Е" => "СКЛЕИВАЙ ВОТ ТАКИЕ" казусы - "location": строка, аудитории через запятую (или "Не указана") - "teacher": строка, ФИО преподавателя(ей) через запятую (или "Не указан") Типы локаций ГУК - ГЛАВНЫЙ УЧЕБНЫЙ КОРПУС, если не указан корпус, пример: 400, значит речь идёт о гуке. А - корпус А, пример: А-400б, А-404 Б - корпус Б, пример: Б-600 В - корпус В (высотка), пример: В-1203, ЛК - корпус ЛК, пример: ЛК-100 Т - корпус на ВГТЗ (на тракторном), пример: Т-200 Корпус-Кабинет Корпус всегда на кириллице, всегда пиши дифис. Если место СИЛЬНО отличается, например спорт. зал, то оставь как есть строку. В входных данных забывают дефис, иногда пишут криво например: "А-400,404" - очевидно ты должен выдать две локации в корпусе А, составители просто ленивые Если корпус ГУК, то префикса писать не надо, просто 100, 200, 400 и т.д. Если в raw есть КИРОВСКИЙ то сделай префикс КИР- Если ВГТЗ то Т- Если Красноармейский то префикс КРАСН- Верни ТОЛЬКО валидный JSON без форматирования markdown. Входные данные: {json.dumps(batch, ensure_ascii=False)} """ url = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_MODEL}:generateContent?key={GEMINI_API_KEY}" payload = { "contents": [{"role": "user", "parts": [{"text": prompt}]}], "generationConfig": {"responseMimeType": "application/json"} } while True: # Цикл ожидания при лимитах try: resp = requests.post(url, json=payload, proxies=proxies, timeout=60*3) if resp.status_code == 429: log("[!] Лимит запросов Gemini (429). Ждем 60 секунд...") time.sleep(60) continue resp.raise_for_status() parsed_res = json.loads(resp.json()['candidates'][0]['content']['parts'][0]['text']) # Сохраняем немедленно cache.update(parsed_res) save_json(FILE_CACHE, cache) log(f"[+] Батч успешно сохранен в {FILE_CACHE}") break # Успех, выходим из цикла ожидания except Exception as e: log(f"[!!!] Ошибка батча: {e}") break # Фатальная ошибка батча, идем к следующему return results def job_iteration(): log("--- Начало итерации обновления расписания ---") cache = load_json(FILE_CACHE, {}) log(f"[*] Скачивание {URL_FACULTETS} ...") try: facultets_data = fetch_json_robust(URL_FACULTETS, timeout=120) except Exception as e: log(f"[!!!] Ошибка скачивания facultets.json: {e}") return "error" # ШАГ 1: Единоразовая загрузка монолита log(f"[*] Скачивание all-in-one файла {URL_RESULT_V2} ...") try: v2_data = fetch_json_robust(URL_RESULT_V2, timeout=120) except Exception as e: log(f"[!!!] Ошибка скачивания базового файла расписаний: {e}") return "error" all_files = v2_data.get("all_files", []) if not all_files: log("[!] Ключ 'all_files' пуст или отсутствует в result_v2.json") return # ШАГ 2: Предварительный обход для сбора неизвестных ячеек (ОРИГИНАЛЬНАЯ ЛОГИКА ОБХОДА) unknown_raws = {} def process_unknown_raw_events(pair_data): events = pair_data if isinstance(pair_data, list) else [pair_data] for event in events: if not isinstance(event, dict): continue raw_list = event.get("raw", []) if not raw_list: continue r_hash = get_raw_hash(raw_list) if r_hash not in cache and r_hash not in unknown_raws: unknown_raws[r_hash] = raw_list for excel_file_dict in all_files: sheets = excel_file_dict.get("sheets", {}) for sheet_dict in sheets.values(): groups = sheet_dict.get("groups", {}) for group_dict in groups.values(): slots = group_dict.get("slots", {}) # slot_key = ПОНЕДЕЛЬНИК etc... for slot_key, pair_value in slots.items(): if isinstance(pair_value, dict): is_event_object = pair_value.get("object", None) == 'event' if is_event_object: process_unknown_raw_events(pair_value) else: for pair_key, pair_data in pair_value.items(): if not (isinstance(pair_key, str) and '-' in pair_key): continue process_unknown_raw_events(pair_data) # ШАГ 3: Вызов ИИ (Только для новых строк) if unknown_raws: log(f"[*] Найдено {len(unknown_raws)} новых уникальных записей. Обращаемся к Gemini API...") ai_data = ask_gemini(unknown_raws, cache) if ai_data: cache.update(ai_data) save_json(FILE_CACHE, cache) log(f"[+] Кэш обновлен, добавлено {len(ai_data)} записей.") # ШАГ 4: Сборка финального графа final_groups = {} excels_list = [] def process_event_for_group(group_key, event): if (isinstance(event, list)): for x in event: process_event_for_group(group_key, x) return is_event_object = False try: is_event_object = event.get("object", None) == 'event' except Exception as e: traceback.print_exception(e) print(event) if not is_event_object: log("[WTF] process_event_for_group is not event object :<") return raw_list = event.get("raw", []) slot_key = get_slot_key_for_event(event) if slot_key not in final_groups[group_key]["slots"]: final_groups[group_key]["slots"][slot_key] = {} r_hash = get_raw_hash(raw_list) res = cache.get(r_hash, {}) locs = [l.strip() for l in res.get("location", "").split(",")] if res.get("location") and res.get("location") not in ["Не указана", "Не указан"] else [] leads = [l.strip() for l in res.get("teacher", "").split(",")] if res.get("teacher") and res.get("teacher") not in ["Не указана", "Не указан"] else [] final_groups[group_key]["slots"][slot_key] = { "discipline_name": res.get("subject", "Не указан"), "locations": locs, "leads": leads, "is_solid": event.get("is_solid", True), "is_flow": event.get("is_flow", False), "raw": raw_list, "weekday": event.get("weekday"), "weeknum": event.get("weeknum"), "excel_range": event.get("excel_range"), "excel_pos": event.get("excel_pos"), "raw_hash": r_hash } log("[*] Сборка структуры слияния...") for excel_file_dict in all_files: excel = excel_file_dict.get("excel", {}) excel_url = excel['url'] excel_facultet = excel['facultet'] preferer_facultet = get_preferer_facultet(facultets_data, excel_url, skip_for=['asp', 'mag']) group_prefix = get_abbrev_for_facultet(facultets_data, excel_facultet) + "/" if preferer_facultet != excel_facultet and preferer_facultet is not None: group_prefix = get_abbrev_for_facultet(facultets_data, preferer_facultet) + "/" + group_prefix file_ds_hash = excel.get("sha1hash", "UNKNOWN") sheets = excel_file_dict.get("sheets", {}) # Сборка метаданных экселя excel_meta = { "data_source_hash": file_ds_hash, "prefix_groups": group_prefix, "facultet": excel_facultet, "preferer_facultet": preferer_facultet, "url": excel_url, "week_keys_metadata": merge_week_keys_metadata(sheets) } for k, v in excel.items(): if k not in ["sheets", "data_source_hash", "week_keys_metadata"] and not isinstance(v, (dict, list)): excel_meta[k] = v excels_list.append(excel_meta) # Обработка групп (ОРИГИНАЛЬНАЯ ЛОГИКА ОБХОДА) for sheet_dict in sheets.values(): groups = sheet_dict.get("groups", {}) for group_id, group_dict in groups.items(): group_name = group_dict["name"] group_key = (group_prefix + group_name.replace(" ", "")).upper() if group_key not in final_groups: final_groups[group_key] = { "name": group_name, "facultet": excel_facultet, "preferer_facultet": preferer_facultet, "position": group_dict.get("position"), "position_human": group_dict.get("position_human"), "slots": {}, "data_source_hash": file_ds_hash } else: if file_ds_hash not in final_groups[group_key]["data_source_hash"]: final_groups[group_key]["data_source_hash"] += f",{file_ds_hash}" slots = group_dict.get("slots", {}) # slot_key as ПОНЕДЕЛЬНИК_1 etc for slot_key, pair_value in slots.items(): if isinstance(pair_value, dict): is_event_object = pair_value.get("object", None) == 'event' if is_event_object: process_event_for_group(group_key, pair_value) else: for key, value in pair_value.items(): process_event_for_group(group_key, value) elif isinstance(pair_value, list): for xxx in pair_value: process_event_for_group(group_key, xxx) # Формируем финальный результат ТОЧНО в первоначальном виде output = { "version": 1, "notice": "ОТКАЗ ОТ ОТВЕТСТВЕННОСТИ: Данные, доступ к API и т.д. предоставляется КАК-ЕСТЬ (AS-IS) без каких либо, явно или не явно подразумеваемых гарантий.\n\nПарсер написал: Миронов Станислав\n\nИсточник данных: https://www.vstu.ru/student/raspisaniya/zanyatiy/index.php", "documentation": "vstu_compat_v1", "daypicture": "vstu_compat_v1", "daycite": "vstu_compat_v1 | running on a rope", "contact": "https://fazziclay.com/", "university": "VSTU", "university_site": "https://www.vstu.ru/", "source": "https://fazziclay.com/api/v1/vstu_schedule_parser/result.json (vstu_compat_v1)", "stat": {"total_parsing_time": -1}, "api_notices": { "updated_at": 1774101882, "text_old": "Пожалуйста сохраняйте 'updated_at', это время изменения ЭТОГО текста. Тут возможно будут появлятся важные BREAKING CHANGES и дедлайны к ним.\nПо хорошему если updated_at другой по сравнению с вашем кэшем это сообщение должно отправляться вам в телеграм как уведомление о поедстоящих изменениях\nwarning=True значит 'text' содержит важное а не как щас hint.\n\n ~fazziclay aka Stanislav;", "text": "ОБНОВЛЕНИЕ 2026-03: теперь этот файл не является результатом работы парсера. Но продолжает функционировать благодаря vstu_compat_v1 (слою совместимости) Новый парсер https://fazziclay.com/api/v1/vstu_schedule_parser_v2/", "vstu_compat_v1": "Этот файл сгенерирован для поддержки совместимости, данные на самом деле взяты из парсера v2: https://fazziclay.com/api/v1/vstu_schedule_parser_v2/", "warning": True, "tut-plavayuschaya-struktura": "required only 'updated_at', 'text' and 'warning'" }, "doubled_groups": [], "debug": {"bleu~~": 1}, "actual_at": int(datetime.now().timestamp()), "groups": final_groups, "excels": excels_list } save_json(FILE_RESULT, output, sort_keys=True) log(f"[+] Сборка графа завершена. Уникальных групп: {len(final_groups)}. Файл {FILE_RESULT} обновлен.") # ... Логика RabbitMQ остается без изменений ... if not (RABBITMQ_USER and RABBITMQ_PASS): log("[!] Данные RabbitMQ не установлены. Пропуск отправки.") return try: credentials = pika.PlainCredentials(RABBITMQ_USER, RABBITMQ_PASS) connection = pika.BlockingConnection(pika.ConnectionParameters( host=RABBITMQ_HOST, credentials=credentials, heartbeat=600, blocked_connection_timeout=300 )) channel = connection.channel() channel.exchange_declare(exchange=EXCHANGE_NAME, exchange_type='fanout') with open(FILE_RESULT, 'r', encoding='utf-8') as fp: channel.basic_publish( exchange=EXCHANGE_NAME, routing_key='', properties=pika.BasicProperties(content_encoding='utf-8', content_type='application/json', delivery_mode=2), body=fp.read() ) log(f" [x] Данные опубликованы в RabbitMQ: {EXCHANGE_NAME}") connection.close() except Exception as mq_err: log(f"[!!!] Ошибка RabbitMQ: {mq_err}") def parser_loop(): log("=== Система Совместимости V1-V2 (Оптимизированная) запущена ===") os.makedirs(os.path.dirname(FILE_RESULT), exist_ok=True) if not GEMINI_API_KEY: log("[!] GEMINI_API_KEY не задан!") while True: devider = 1 try: r = job_iteration() if r == "error": devider = 10 print("returned error; devider now = 10") except Exception as e: log(f"[!!!] Критическая ошибка: {e}") traceback.print_exc() devider = 5 log(f"devider now = 5") delay = round(POLL_INTERVAL / devider) log(f"[*] Ожидание {delay} сек...\n") time.sleep(delay) if __name__ == "__main__": threading.Thread(target=parser_loop, daemon=True).start() uvicorn.run(app, host="0.0.0.0", port=HTTP_PORT)