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 from requests.adapters import HTTPAdapter from urllib3 import Retry 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 fetch_json_robust(url, timeout=120): """ Устойчивый HTTP-клиент с маскировкой под браузер и механизмом Retry. Адаптирован для обхода базовых проверок Cloudflare. """ session = requests.Session() # Маскировка под стандартный браузер session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept": "application/json", "Accept-Encoding": "gzip, deflate", # Оптимизация получения 2.4 МБ "Connection": "keep-alive" }) # Настройка стратегии повторных попыток # 3 попытки, задержки: 2с, 4с, 8с. Отработка ошибок таймаутов Cloudflare (522, 524) retry_strategy = Retry( total=3, backoff_factor=2, status_forcelist=[429, 500, 502, 503, 504, 522, 524], allowed_methods=["GET"] ) adapter = HTTPAdapter(max_retries=retry_strategy) session.mount("http://", adapter) session.mount("https://", adapter) proxies = {"http": PROXY_URL, "https": PROXY_URL} if PROXY_URL else None response = session.get(url, timeout=timeout, proxies=proxies) response.raise_for_status() return response.json() def job_iteration(): log("--- Начало итерации обновления расписания ---") cache = load_json(FILE_CACHE, {}) # ШАГ 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 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)