Files
vstu_compat_v1/main.py
2026-03-21 17:57:23 +03:00

291 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 requests
import time
import traceback
from datetime import datetime
# --- Загрузка переменных окружения ---
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/"
FILE_RESULT = "data/result.json"
FILE_CACHE = "data/raw_cache.json"
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")
# =================================================
def log(msg):
"""Логирование с принудительным сбросом буфера (важно для Docker)."""
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):
"""Объединяет week_keys_metadata со всех листов, исключая дубликаты дней."""
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 = {}
if PROXY_URL:
log("Using proxy")
proxies = {"http": PROXY_URL, "https": PROXY_URL}
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" # ИСПРАВЛЕНИЕ: строго camelCase для REST API
}
}
try:
resp = requests.post(url, json=payload, proxies=proxies, timeout=60)
resp.raise_for_status() # Выкинет ошибку для 400, 403, 404 и т.д.
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:
# ИСПРАВЛЕНИЕ: Читаем текст ответа от 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}")
except Exception as e:
log(f"[!] Ошибка обращения к API Gemini: {e}")
return results
def fetch_and_build(cache):
log("[*] Загрузка списка файлов парсера V2...")
parser_data = requests.get(URL_PARSER_ROOT, timeout=30).json()
final_groups = {}
excels_list =[]
unknown_raws = {}
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']}")
try:
faculty_data = requests.get(file_url, timeout=30).json()
except Exception as e:
log(f"[!] Ошибка скачивания {file_url}: {e}")
continue
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"]:
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', 'Неизвестно'),
"position": group_data.get("position"),
"position_human": group_data.get("position_human"),
"slots": {},
"data_source_hash": 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] = {}
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 i, event in enumerate(events):
current_pair_id = pair_key if i == 0 else f"{pair_key}_{i}"
if not isinstance(event, dict): continue
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 []
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
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",
"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"[+] Итерация завершена. Файл {FILE_RESULT} успешно обновлен.")
def main():
log("=== Система Совместимости V1-V2 (Служба) запущена ===")
if PROXY_URL:
log(f"[*] Настроен прокси-сервер: {PROXY_URL}")
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__":
main()