import json import os import hashlib import threading import pika import requests import time import traceback from datetime import datetime from fastapi import FastAPI from fastapi.responses import FileResponse import uvicorn # --- Загрузка переменных окружения --- from dotenv import load_dotenv load_dotenv() # ----------------------------------- # ================= КОНФИГУРАЦИЯ ================= # ИЗМЕНЕНИЕ 1: Отказываемся от списка файлов. Грузим монолит. URL_RESULT_V2 = "https://fazziclay.com/api/v1/vstu_schedule_parser_v2/result_v2.json" FILE_RESULT = "data/result.json" FILE_CACHE = "data/raw_cache.json" GEMINI_MODEL="gemini-flash-lite-latest" POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", 600)) GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY") PROXY_URL = os.environ.get("PROXY_URL") RABBITMQ_HOST = os.environ.get("RABBITMQ_HOST", "172.20.0.10") RABBITMQ_USER = os.environ.get("RABBITMQ_USER") RABBITMQ_PASS = os.environ.get("RABBITMQ_PASS") EXCHANGE_NAME = os.environ.get("EXCHANGE_NAME", "vstu_schedule_parser.schedule_parsed") HTTP_PORT = int(os.environ.get("HTTP_PORT", 8080)) # ================================================= app = FastAPI(title="VSTU Compatibility API") @app.get("/result.json") async def get_result(): if os.path.exists(FILE_RESULT): return FileResponse(FILE_RESULT, media_type="application/json") return {"error": "Файл еще не сгенерирован. Попробуйте позже."}, 404 def log(msg): print(f"[{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}] {msg}", flush=True) def get_raw_hash(raw_list): normalized = "|".join(sorted([str(i).strip() for i in raw_list])) return hashlib.sha1(normalized.encode('utf-8')).hexdigest() def load_json(filename, default): if os.path.exists(filename): with open(filename, 'r', encoding='utf-8') as f: try: return json.load(f) except: return default return default def save_json(filename, data): with open(filename, 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False, indent=2) 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] = [] for d in days: if d not in merged[day][month]: merged[day][month].append(d) return merged def ask_gemini(unknown_raws): 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": строка, название предмета (или "Не указан") - "location": строка, аудитории через запятую (или "Не указана") - "teacher": строка, ФИО преподавателя(ей) через запятую (или "Не указан") Верни ТОЛЬКО валидный 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"} } try: resp = requests.post(url, json=payload, proxies=proxies, timeout=60) resp.raise_for_status() data = resp.json() text_response = data['candidates'][0]['content']['parts'][0]['text'] parsed_batch = json.loads(text_response) results.update(parsed_batch) log(f"[+] Батч успешно обработан ИИ ({GEMINI_MODEL}).") except requests.exceptions.HTTPError as e: error_body = e.response.text if e.response is not None else "Нет тела ответа" log(f"[!] HTTP Ошибка API Gemini ({e.response.status_code}): {error_body}") except Exception as e: log(f"[!] Ошибка обращения к API Gemini: {e}") return results def job_iteration(): log("--- Начало итерации обновления расписания ---") cache = load_json(FILE_CACHE, {}) # ШАГ 1: Единоразовая загрузка монолита log(f"[*] Скачивание all-in-one файла {URL_RESULT_V2} ...") try: v2_data = requests.get(URL_RESULT_V2, timeout=60).json() except Exception as e: log(f"[!!!] Ошибка скачивания базового файла расписаний: {e}") return items_to_process = v2_data.get("all_files", []) if not items_to_process: log("[!] Ключ 'all_files' пуст или отсутствует в result_v2.json") return # ШАГ 2: Предварительный обход для сбора неизвестных ячеек (ОРИГИНАЛЬНАЯ ЛОГИКА ОБХОДА) unknown_raws = {} for file_data in items_to_process: sheets = file_data.get("sheets", {}) for sheet_data in sheets.values(): for group_data in sheet_data.get("groups", {}).values(): slots = group_data.get("slots", {}) for slot_key, pair_value in slots.items(): if not isinstance(pair_value, dict): continue for pair_key, pair_data in pair_value.items(): if not (isinstance(pair_key, str) and '-' in pair_key): continue 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 # ШАГ 3: Вызов ИИ (Только для новых строк) if unknown_raws: log(f"[*] Найдено {len(unknown_raws)} новых уникальных записей. Обращаемся к Gemini API...") ai_data = ask_gemini(unknown_raws) if ai_data: cache.update(ai_data) save_json(FILE_CACHE, cache) log(f"[+] Кэш обновлен, добавлено {len(ai_data)} записей.") # ШАГ 4: Сборка финального графа final_groups = {} excels_list = [] log("[*] Сборка структуры слияния...") for file_data in items_to_process: excel_info = file_data.get("excel", {}) sheets = file_data.get("sheets", {}) file_ds_hash = excel_info.get("sha1hash", "UNKNOWN") # Сборка метаданных экселя excel_meta = { "data_source_hash": file_ds_hash, "week_keys_metadata": merge_week_keys_metadata(sheets) } for k, v in excel_info.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_data in sheets.values(): groups = sheet_data.get("groups", {}) for group_id, group_data in groups.items(): group_name = group_data["name"] if group_name not in final_groups: final_groups[group_name] = { "name": group_name, "facultet": excel_info.get('facultet', 'Неизвестно'), "position": group_data.get("position"), "position_human": group_data.get("position_human"), "slots": {}, "data_source_hash": file_ds_hash } else: if file_ds_hash not in final_groups[group_name]["data_source_hash"]: final_groups[group_name]["data_source_hash"] += f",{file_ds_hash}" slots = group_data.get("slots", {}) for slot_key, pair_value in slots.items(): if not isinstance(pair_value, dict): continue if slot_key not in final_groups[group_name]["slots"]: final_groups[group_name]["slots"][slot_key] = {} target_slot = final_groups[group_name]["slots"][slot_key] for pair_key, pair_data in pair_value.items(): if not (isinstance(pair_key, str) and '-' in pair_key): continue events = pair_data if isinstance(pair_data, list) else [pair_data] for event in events: if not isinstance(event, dict): continue # Логика склейки дубликатов (1-2, 1-2_1) current_pair_id = pair_key counter = 1 while current_pair_id in target_slot: current_pair_id = f"{pair_key}_{counter}" counter += 1 raw_list = event.get("raw", []) 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 [] target_slot[current_pair_id] = { "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") } # Формируем финальный результат ТОЧНО в первоначальном виде 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) 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: try: job_iteration() except Exception as e: log(f"[!!!] Критическая ошибка: {e}") traceback.print_exc() log(f"[*] Ожидание {POLL_INTERVAL} сек...\n") time.sleep(POLL_INTERVAL) if __name__ == "__main__": threading.Thread(target=parser_loop, daemon=True).start() uvicorn.run(app, host="0.0.0.0", port=HTTP_PORT)