Files
vstu_compat_v1/main-bad.py
2026-03-29 12:44:55 +03:00

285 lines
15 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 hashlib
import threading
import pika
import requests
import time
import traceback
import re
from datetime import datetime
from fastapi import FastAPI
from fastapi.responses import FileResponse
import uvicorn
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from dotenv import load_dotenv
load_dotenv()
# ================= КОНФИГУРАЦИЯ =================
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 = os.environ.get("GEMINI_MODEL", "gemini-flash-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 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 get_raw_hash(raw_list):
"""Детерминированный короткий хэш контента"""
if not raw_list: return "empty"
normalized = "|".join(sorted([str(i).strip() for i in raw_list if str(i).strip()]))
return hashlib.sha1(normalized.encode('utf-8')).hexdigest()[:10]
def fetch_json_robust(url, timeout=120):
session = requests.Session()
session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36",
"Accept": "application/json",
"Accept-Encoding": "gzip, deflate"
})
retry_strategy = Retry(
total=3, backoff_factor=2,
status_forcelist=[500, 502, 503, 504, 522, 524]
)
session.mount("https://", HTTPAdapter(max_retries=retry_strategy))
proxies = {"http": PROXY_URL, "https": PROXY_URL} if PROXY_URL else None
resp = session.get(url, timeout=timeout, proxies=proxies)
resp.raise_for_status()
return resp.json()
def get_events_from_slot(slot_data):
"""Генерирует ID: hash_raw + times + pairs"""
results = []
def generate_content_id(ev):
raw_h = get_raw_hash(ev.get("raw", []))
times_p = "-".join(sorted([str(t) for t in ev.get("times", [])])).replace(":", "-")
pairs_p = "-".join(sorted([str(p) for p in ev.get("pairs", [])]))
parts = [raw_h]
if times_p: parts.append(times_p)
if pairs_p: parts.append(pairs_p)
return "_".join(parts)
if isinstance(slot_data, list):
for i, ev in enumerate(slot_data): results.append((generate_content_id(ev), ev))
elif isinstance(slot_data, dict):
if "object" in slot_data or "raw" in slot_data:
results.append((generate_content_id(slot_data), slot_data))
else:
for pk, pv in slot_data.items():
evs = pv if isinstance(pv, list) else [pv]
for ev in evs: results.append((generate_content_id(ev), ev))
return results
def ask_gemini(unknown_raws, cache):
"""Обработка батчей с сохранением после каждого и ожиданием при 429 ошибке"""
if not GEMINI_API_KEY: return
proxies = {"http": PROXY_URL, "https": PROXY_URL} if PROXY_URL else {}
items = list(unknown_raws.items())
batch_size = 10
for i in range(0, len(items), batch_size):
batch = dict(items[i:i+batch_size])
log(f"[*] Батч Gemini {i//batch_size + 1}/{len(items)//batch_size + 1}...")
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 # Фатальная ошибка батча, идем к следующему
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 job_iteration():
log("--- Старт итерации ---")
cache = load_json(FILE_CACHE, {})
try:
v2_data = fetch_json_robust(URL_RESULT_V2)
except Exception as e: log(f"[!!!] Ошибка загрузки данных: {e}"); return
items = v2_data.get("all_files", [])
unknown_raws = {}
# Поиск новых строк
for f in items:
for s in f.get("sheets", {}).values():
for g in s.get("groups", {}).values():
for sk, sc in g.get("slots", {}).items():
for _, ev in get_events_from_slot(sc):
rh = get_raw_hash(ev.get("raw", []))
if rh not in cache: unknown_raws[rh] = ev.get("raw", [])
if unknown_raws:
log(f"[*] Найдено {len(unknown_raws)} новых ячеек.")
ask_gemini(unknown_raws, cache)
# Пересборка финального файла
final_groups = {}
excels_list = []
for f in items:
ex_info = f.get("excel", {})
ds_hash = ex_info.get("sha1hash", "UNKNOWN")
excels_list.append({**{k:v for k,v in ex_info.items() if not isinstance(v,(dict,list))},
"data_source_hash": ds_hash, "week_keys_metadata": merge_week_keys_metadata(f.get("sheets",{}))})
for s in f.get("sheets", {}).values():
for g_id, g_data in s.get("groups", {}).items():
name = g_data["name"]
if name not in final_groups:
final_groups[name] = {"name": name, "facultet": ex_info.get('facultet', 'Unknown'),
"position": g_data.get("position"), "position_human": g_data.get("position_human"),
"slots": {}, "data_source_hash": ds_hash}
for sk, sc in g_data.get("slots", {}).items():
if sk not in final_groups[name]["slots"]: final_groups[name]["slots"][sk] = {}
target_day = final_groups[name]["slots"][sk]
for pid, ev in get_events_from_slot(sc):
res = cache.get(get_raw_hash(ev.get("raw", [])), {})
final_id = pid
c = 1
while final_id in target_day: # Предохранитель коллизий
final_id = f"{pid}_{c}"; c += 1
target_day[final_id] = {
"discipline_name": res.get("subject", "Не указан"),
"locations": [l.strip() for l in res.get("location", "").split(",")] if res.get("location") else [],
"leads": [l.strip() for l in res.get("teacher", "").split(",")] if res.get("teacher") else [],
"raw": ev.get("raw", []), "weekday": ev.get("weekday"), "weeknum": ev.get("weeknum"),
"excel_range": ev.get("excel_range"), "excel_pos": ev.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)}")
if RABBITMQ_USER and RABBITMQ_PASS:
try:
conn = pika.BlockingConnection(pika.ConnectionParameters(host=RABBITMQ_HOST, credentials=pika.PlainCredentials(RABBITMQ_USER, RABBITMQ_PASS)))
ch = conn.channel()
ch.exchange_declare(exchange=EXCHANGE_NAME, exchange_type='fanout')
ch.basic_publish(exchange=EXCHANGE_NAME, routing_key='', body=json.dumps(output, ensure_ascii=False))
conn.close()
except Exception as e: log(f"[!] RabbitMQ Error: {e}")
def parser_loop():
os.makedirs("data", exist_ok=True)
while True:
try: job_iteration()
except Exception: traceback.print_exc()
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)