This commit is contained in:
2026-03-28 14:00:19 +03:00
parent fc739d591d
commit d5cf92858e
5 changed files with 6468 additions and 4119 deletions

277
main.py
View File

@@ -1,31 +1,52 @@
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
import uvicorn
# --- Загрузка переменных окружения ---
from dotenv import load_dotenv
load_dotenv()
# -----------------------------------
# ================= КОНФИГУРАЦИЯ =================
URL_PARSER_ROOT = "https://fazziclay.com/api/v1/vstu_schedule_parser_v2/parser.json"
BASE_URL_FILES = "https://fazziclay.com/api/v1/vstu_schedule_parser_v2/parsed/"
# ИЗМЕНЕНИЕ 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-latest"
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))
# =================================================
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):
"""Логирование с принудительным сбросом буфера (важно для Docker)."""
print(f"[{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}] {msg}", flush=True)
def get_raw_hash(raw_list):
@@ -44,7 +65,6 @@ def save_json(filename, data):
json.dump(data, f, ensure_ascii=False, indent=2)
def merge_week_keys_metadata(sheets):
"""Объединяет week_keys_metadata со всех листов, исключая дубликаты дней."""
merged = {}
for sheet_data in sheets.values():
wkm = sheet_data.get("week_keys_metadata", {})
@@ -53,8 +73,7 @@ def merge_week_keys_metadata(sheets):
merged[day] = {}
for month, days in months.items():
if month not in merged[day]:
merged[day][month] =[]
# Добавляем даты, сохраняя порядок и избегая дублей
merged[day][month] = []
for d in days:
if d not in merged[day][month]:
merged[day][month].append(d)
@@ -65,11 +84,7 @@ def ask_gemini(unknown_raws):
log("[!] GEMINI_API_KEY не задан! ИИ-парсинг пропущен.")
return {}
proxies = {}
if PROXY_URL:
log("Using proxy")
proxies = {"http": PROXY_URL, "https": PROXY_URL}
proxies = {"http": PROXY_URL, "https": PROXY_URL} if PROXY_URL else {}
results = {}
items = list(unknown_raws.items())
batch_size = 40
@@ -92,18 +107,13 @@ def ask_gemini(unknown_raws):
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" # ИСПРАВЛЕНИЕ: строго camelCase для REST API
}
"contents": [{"role": "user", "parts": [{"text": prompt}]}],
"generationConfig": {"responseMimeType": "application/json"}
}
try:
resp = requests.post(url, json=payload, proxies=proxies, timeout=60)
resp.raise_for_status() # Выкинет ошибку для 400, 403, 404 и т.д.
resp.raise_for_status()
data = resp.json()
text_response = data['candidates'][0]['content']['parts'][0]['text']
@@ -113,132 +123,149 @@ def ask_gemini(unknown_raws):
log(f"[+] Батч успешно обработан ИИ ({GEMINI_MODEL}).")
except requests.exceptions.HTTPError as e:
# ИСПРАВЛЕНИЕ: Читаем текст ответа от Google (там написана причина)
error_body = e.response.text if e.response is not None else "Нет тела ответа"
if e.response.status_code == 404:
log(f"[!] Ошибка 404: Модель '{GEMINI_MODEL}' не найдена. Обновите GEMINI_MODEL в .env!")
elif e.response.status_code == 400:
log(f"[!] Ошибка 400 (Bad Request): Неверный формат запроса. Ответ Google:\n{error_body}")
else:
log(f"[!] HTTP Ошибка API Gemini: {e}\nОтвет: {error_body}")
log(f"[!] HTTP Ошибка API Gemini ({e.response.status_code}): {error_body}")
except Exception as e:
log(f"[!] Ошибка обращения к API Gemini: {e}")
return results
def job_iteration():
log("--- Начало итерации обновления расписания ---")
cache = load_json(FILE_CACHE, {})
# ШАГ 1: Единоразовая загрузка монолита
log(f"[*] Скачивание all-in-one файла {URL_RESULT_V2} ...")
try:
v2_data = requests.get(URL_RESULT_V2, timeout=60).json()
except Exception as e:
log(f"[!!!] Ошибка скачивания базового файла расписаний: {e}")
return
def fetch_and_build(cache):
log("[*] Загрузка списка файлов парсера V2...")
parser_data = requests.get(URL_PARSER_ROOT, timeout=30).json()
items_to_process = v2_data.get("all_files", [])
if not items_to_process:
log("[!] Ключ 'all_files' пуст или отсутствует в result_v2.json")
return
final_groups = {}
excels_list =[]
# ШАГ 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", {})
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
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 file_info in parser_data.get("all_files",[]):
file_url = f"{BASE_URL_FILES}{requests.utils.quote(file_info['json_represent'])}"
log(f"[*] Обработка файла: {file_info['json_represent']}")
# ШАГ 3: Вызов ИИ (Только для новых строк)
if unknown_raws:
log(f"[*] Найдено {len(unknown_raws)} новых уникальных записей. Обращаемся к Gemini API...")
ai_data = ask_gemini(unknown_raws)
if ai_data:
cache.update(ai_data)
save_json(FILE_CACHE, cache)
log(f"[+] Кэш обновлен, добавлено {len(ai_data)} записей.")
# ШАГ 4: Сборка финального графа
final_groups = {}
excels_list = []
log("[*] Сборка структуры слияния...")
for file_data in items_to_process:
excel_info = file_data.get("excel", {})
sheets = file_data.get("sheets", {})
try:
faculty_data = requests.get(file_url, timeout=30).json()
except Exception as e:
log(f"[!] Ошибка скачивания {file_url}: {e}")
continue
file_ds_hash = excel_info.get("sha1hash", "UNKNOWN")
file_ds_hash = faculty_data.get("sha1hash", faculty_data.get("excel", {}).get("sha1hash", "UNKNOWN"))
sheets = faculty_data.get("sheets", {})
# --- СБОРКА ОБЪЕКТА EXCEL (Обновлено) ---
# Сборка метаданных экселя
excel_meta = {
"data_source_hash": file_ds_hash,
"week_keys_metadata": merge_week_keys_metadata(sheets)
}
# Переносим остальные корневые ключи из оригинального json эксельки (за исключением уже обработанных)
for k, v in faculty_data.items():
if k not in ["sheets", "data_source_hash", "week_keys_metadata"]:
for k, v in excel_info.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 group_id, group_data in groups.items():
if group_data["name"] not in final_groups:
final_groups[group_data["name"]] = {
"name": group_data["name"],
"facultet": faculty_data.get('excel', {}).get('facultet', 'Неизвестно'),
group_name = group_data["name"]
if group_name not in final_groups:
final_groups[group_name] = {
"name": group_name,
"facultet": excel_info.get('facultet', 'Неизвестно'),
"position": group_data.get("position"),
"position_human": group_data.get("position_human"),
"slots": {},
"data_source_hash": file_ds_hash
"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}"
slots = group_data.get("slots", {})
for slot_key, pair_value in slots.items():
if not isinstance(pair_value, dict): continue
if slot_key not in final_groups[group_data["name"]]["slots"]:
final_groups[group_data["name"]]["slots"][slot_key] = {}
if slot_key not in final_groups[group_name]["slots"]:
final_groups[group_name]["slots"][slot_key] = {}
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]
events = pair_data if isinstance(pair_data, list) else [pair_data]
for i, event in enumerate(events):
current_pair_id = pair_key if i == 0 else f"{pair_key}_{i}"
for event in events:
if not isinstance(event, dict): continue
raw_list = event.get("raw",[])
# Логика склейки дубликатов (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)
if r_hash in cache:
res = cache[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 []
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_data["name"]]["slots"][slot_key][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")
}
else:
unknown_raws[r_hash] = raw_list
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")
}
return final_groups, excels_list, unknown_raws
def job_iteration():
log("--- Начало итерации обновления расписания ---")
cache = load_json(FILE_CACHE, {})
# ПЕРВЫЙ ПРОХОД: собираем данные и ищем неизвестные строки
final_groups, excels_list, unknown_raws = fetch_and_build(cache)
# Если есть неизвестные строки — отправляем в ИИ
if unknown_raws:
log(f"[*] Найдено {len(unknown_raws)} новых уникальных записей. Обращаемся к Gemini API...")
ai_data = ask_gemini(unknown_raws)
if ai_data:
cache.update(ai_data)
save_json(FILE_CACHE, cache)
log(f"[+] Кэш обновлен, добавлено {len(ai_data)} записей.")
# ВТОРОЙ ПРОХОД: пересобираем расписание уже с новыми кэшированными данными ИИ
log("[*] Пересборка структуры с учетом новых данных от ИИ...")
final_groups, excels_list, _ = fetch_and_build(cache)
# Формируем финальный результат
# Формируем финальный результат ТОЧНО в первоначальном виде
output = {
"version": 1,
"notice": "ОТКАЗ ОТ ОТВЕТСТВЕННОСТИ: Данные, доступ к API и т.д. предоставляется КАК-ЕСТЬ (AS-IS) без каких либо, явно или не явно подразумеваемых гарантий.\n\nПарсер написал: Миронов Станислав\n\nИсточник данных: https://www.vstu.ru/student/raspisaniya/zanyatiy/index.php",
@@ -267,25 +294,49 @@ def job_iteration():
}
save_json(FILE_RESULT, output)
log(f"[+] Итерация завершена. Файл {FILE_RESULT} успешно обновлен.")
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 main():
log("=== Система Совместимости V1-V2 (Служба) запущена ===")
if PROXY_URL:
log(f"[*] Настроен прокси-сервер: {PROXY_URL}")
if not GEMINI_API_KEY:
log("[!] ВНИМАНИЕ: GEMINI_API_KEY не задан. Интеллектуальный парсинг работать не будет!")
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:
try:
job_iteration()
except Exception as e:
log(f"[!!!] Критическая ошибка на верхнем уровне: {e}")
log(f"[!!!] Критическая ошибка: {e}")
traceback.print_exc()
log(f"[*] Ожидание {POLL_INTERVAL} секунд перед следующей итерацией...\n")
log(f"[*] Ожидание {POLL_INTERVAL} сек...\n")
time.sleep(POLL_INTERVAL)
if __name__ == "__main__":
main()
threading.Thread(target=parser_loop, daemon=True).start()
uvicorn.run(app, host="0.0.0.0", port=HTTP_PORT)