changes
This commit is contained in:
285
main-bad.py
Normal file
285
main-bad.py
Normal file
@@ -0,0 +1,285 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user