285 lines
15 KiB
Python
285 lines
15 KiB
Python
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) |