Files
vstu_compat_v1/main.py
FazziCLAY 280ca728d2
All checks were successful
Build and Run VSTU Compat Layer v1 / build_and_run (push) Successful in 7s
fix
2026-04-03 12:59:58 +03:00

436 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)