379 lines
18 KiB
Python
379 lines
18 KiB
Python
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))
|
||
# =================================================
|
||
|
||
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)
|
||
|
||
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 ask_gemini(unknown_raws):
|
||
if not GEMINI_API_KEY:
|
||
log("[!] GEMINI_API_KEY не задан! ИИ-парсинг пропущен.")
|
||
return {}
|
||
|
||
proxies = {"http": PROXY_URL, "https": PROXY_URL} if PROXY_URL else {}
|
||
results = {}
|
||
items = list(unknown_raws.items())
|
||
batch_size = 40
|
||
|
||
for i in range(0, len(items), batch_size):
|
||
batch = dict(items[i:i+batch_size])
|
||
log(f"[*] Отправка батча в Gemini ({i+1}-{min(i+batch_size, len(items))} из {len(items)})...")
|
||
|
||
prompt = f"""
|
||
Ты парсер академического расписания. Я даю тебе JSON, где ключи - хэши, а значения - массив сырых строк расписания.
|
||
Твоя задача вернуть JSON, где ключи - те же хэши, а значения - объекты с ключами:
|
||
- "subject": строка, название предмета (или "Не указан")
|
||
- "location": строка, аудитории через запятую (или "Не указана")
|
||
- "teacher": строка, ФИО преподавателя(ей) через запятую (или "Не указан")
|
||
|
||
Верни ТОЛЬКО валидный 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"}
|
||
}
|
||
|
||
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}")
|
||
|
||
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, {})
|
||
|
||
# ШАГ 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
|
||
|
||
items_to_process = v2_data.get("all_files", [])
|
||
if not items_to_process:
|
||
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", {})
|
||
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
|
||
|
||
# ШАГ 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", {})
|
||
|
||
file_ds_hash = excel_info.get("sha1hash", "UNKNOWN")
|
||
|
||
# Сборка метаданных экселя
|
||
excel_meta = {
|
||
"data_source_hash": file_ds_hash,
|
||
"week_keys_metadata": merge_week_keys_metadata(sheets)
|
||
}
|
||
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():
|
||
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
|
||
}
|
||
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_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]
|
||
|
||
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")
|
||
}
|
||
|
||
# Формируем финальный результат ТОЧНО в первоначальном виде
|
||
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)}. Файл {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 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}")
|
||
traceback.print_exc()
|
||
|
||
log(f"[*] Ожидание {POLL_INTERVAL} сек...\n")
|
||
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) |