This commit is contained in:
2026-03-29 12:44:55 +03:00
parent 8b5a71f1bf
commit 673a7eb6b4
9 changed files with 629 additions and 173168 deletions

430
main.py
View File

@@ -1,70 +1,22 @@
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))
# =================================================
import consts
import httpserver
from utils import *
from consts import *
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)
app.include_router(httpserver.router)
def merge_week_keys_metadata(sheets):
merged = {}
@@ -81,7 +33,64 @@ def merge_week_keys_metadata(sheets):
merged[day][month].append(d)
return merged
def ask_gemini(unknown_raws):
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 {}
@@ -98,10 +107,26 @@ def ask_gemini(unknown_raws):
prompt = f"""
Ты парсер академического расписания. Я даю тебе JSON, где ключи - хэши, а значения - массив сырых строк расписания.
Твоя задача вернуть JSON, где ключи - те же хэши, а значения - объекты с ключами:
- "subject": строка, название предмета (или "Не указан")
- "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)}
@@ -113,105 +138,96 @@ def ask_gemini(unknown_raws):
"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}")
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 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, {})
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
return "error"
items_to_process = v2_data.get("all_files", [])
if not items_to_process:
all_files = v2_data.get("all_files", [])
if not all_files:
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", {})
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 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
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)
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)
ai_data = ask_gemini(unknown_raws, cache)
if ai_data:
cache.update(ai_data)
save_json(FILE_CACHE, cache)
@@ -220,87 +236,115 @@ def job_iteration():
# ШАГ 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 file_data in items_to_process:
excel_info = file_data.get("excel", {})
sheets = file_data.get("sheets", {})
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_info.get("sha1hash", "UNKNOWN")
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_info.items():
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_data in sheets.values():
groups = sheet_data.get("groups", {})
for sheet_dict in sheets.values():
groups = sheet_dict.get("groups", {})
for group_id, group_data in groups.items():
group_name = group_data["name"]
for group_id, group_dict in groups.items():
group_name = group_dict["name"]
group_key = (group_prefix + group_name.replace(" ", "")).upper()
if group_name not in final_groups:
final_groups[group_name] = {
if group_key not in final_groups:
final_groups[group_key] = {
"name": group_name,
"facultet": excel_info.get('facultet', 'Неизвестно'),
"position": group_data.get("position"),
"position_human": group_data.get("position_human"),
"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_name]["data_source_hash"]:
final_groups[group_name]["data_source_hash"] += f",{file_ds_hash}"
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_data.get("slots", {})
slots = group_dict.get("slots", {})
# slot_key as ПОНЕДЕЛЬНИК_1 etc
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] = {}
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)
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")
}
elif isinstance(pair_value, list):
for xxx in pair_value:
process_event_for_group(group_key, xxx)
# Формируем финальный результат ТОЧНО в первоначальном виде
output = {
@@ -330,7 +374,7 @@ def job_iteration():
"excels": excels_list
}
save_json(FILE_RESULT, output)
save_json(FILE_RESULT, output, sort_keys=True)
log(f"[+] Сборка графа завершена. Уникальных групп: {len(final_groups)}. Файл {FILE_RESULT} обновлен.")
# ... Логика RabbitMQ остается без изменений ...
@@ -365,14 +409,22 @@ def parser_loop():
if not GEMINI_API_KEY: log("[!] GEMINI_API_KEY не задан!")
while True:
devider = 1
try:
job_iteration()
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")
log(f"[*] Ожидание {POLL_INTERVAL} сек...\n")
time.sleep(POLL_INTERVAL)
delay = round(POLL_INTERVAL / devider)
log(f"[*] Ожидание {delay} сек...\n")
time.sleep(delay)
if __name__ == "__main__":
threading.Thread(target=parser_loop, daemon=True).start()