All checks were successful
Build and Run VSTU Compat Layer v1 / build_and_run (push) Successful in 7s
436 lines
20 KiB
Python
436 lines
20 KiB
Python
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) |