This commit is contained in:
2026-03-29 12:44:55 +03:00
parent 8b5a71f1bf
commit 673a7eb6b4
9 changed files with 629 additions and 173168 deletions

3
.env
View File

@@ -1,3 +0,0 @@
GEMINI_API_KEY=AIzaSyDTFKJscB72NY7R-zeyZcmO0iTnJRoMusw
PROXY_URL=socks5://127.0.0.1:2081
POLL_INTERVAL=300

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
data/
result.json
raw_cache.json
.env
__pycache__

23
consts.py Normal file
View File

@@ -0,0 +1,23 @@
import os
from dotenv import load_dotenv
load_dotenv()
URL_RESULT_V2 = "https://fazziclay.com/api/v1/vstu_schedule_parser_v2/result_v2.json"
URL_FACULTETS = "https://fazziclay.com/api/v1/vstu_schedule_parser_v2/facultets.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))

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

13
httpserver.py Normal file
View File

@@ -0,0 +1,13 @@
import os
from fastapi import APIRouter
from fastapi.responses import FileResponse
from consts import FILE_RESULT
router = APIRouter()
@router.get("/result.json")
async def get_result():
if os.path.exists(FILE_RESULT):
return FileResponse(FILE_RESULT, media_type="application/json")
return {"error": "Файл еще не сгенерирован. Попробуйте позже."}, 404

285
main-bad.py Normal file
View 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)

424
main.py
View File

@@ -1,70 +1,22 @@
import json import json
import os import os
import hashlib
import threading import threading
import pika import pika
import requests import requests
import time import time
import traceback import traceback
from datetime import datetime from datetime import datetime
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import FileResponse
from requests.adapters import HTTPAdapter
from urllib3 import Retry
import uvicorn import uvicorn
# --- Загрузка переменных окружения --- import consts
from dotenv import load_dotenv import httpserver
load_dotenv() from utils import *
# ----------------------------------- from consts import *
# ================= КОНФИГУРАЦИЯ =================
# ИЗМЕНЕНИЕ 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 = FastAPI(title="VSTU Compatibility API")
app.include_router(httpserver.router)
@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): def merge_week_keys_metadata(sheets):
merged = {} merged = {}
@@ -81,7 +33,64 @@ def merge_week_keys_metadata(sheets):
merged[day][month].append(d) merged[day][month].append(d)
return merged return merged
def ask_gemini(unknown_raws): def get_preferer_facultet(facultets_data: dict, excel_url: str, skip_for=None, ):
if skip_for is None:
skip_for = []
for _key, _value in facultets_data.items():
if _key.startswith("_"):
continue
if _key in skip_for:
continue
short_names = _value.get("short_names", None)
if short_names is None:
continue
for name in short_names:
if name.lower() in excel_url.lower():
return _key
def get_abbrev_for_facultet(facultets_data: dict, facultet_id: str, fallback_not_found="?", fallback_error="?", fallback_no_short_name="?"):
if (facultet_id == 'mag'):
return "МАГ"
if (facultet_id == 'asp'):
return "АСП"
for _key, _value in facultets_data.items():
if _key != facultet_id:
continue
short_names = _value.get("short_names", None)
if short_names is None:
return fallback_no_short_name
try:
return short_names[0]
except Exception as e:
traceback.print_exception(e)
return fallback_error
return fallback_not_found
def get_slot_key_for_event(event: dict):
raw = sorted(event.get("raw", []))
pairs = event.get("pairs", [])
times = event.get("times", [])
weekday = event.get("weekday", None)
weeknum = event.get("weeknum", None)
w = ""
if weeknum is not None:
w = "_WN" + str(weeknum)
if weekday is not None:
w = "_WD" + str(weekday)
r = ('_'.join(pairs)) + ("_".join(times))
if len(r) > 0: r = "_" + r
return get_raw_hash(raw) + w + r
def ask_gemini(unknown_raws, cache):
if not GEMINI_API_KEY: if not GEMINI_API_KEY:
log("[!] GEMINI_API_KEY не задан! ИИ-парсинг пропущен.") log("[!] GEMINI_API_KEY не задан! ИИ-парсинг пропущен.")
return {} return {}
@@ -98,10 +107,26 @@ def ask_gemini(unknown_raws):
prompt = f""" prompt = f"""
Ты парсер академического расписания. Я даю тебе JSON, где ключи - хэши, а значения - массив сырых строк расписания. Ты парсер академического расписания. Я даю тебе JSON, где ключи - хэши, а значения - массив сырых строк расписания.
Твоя задача вернуть JSON, где ключи - те же хэши, а значения - объекты с ключами: Твоя задача вернуть JSON, где ключи - те же хэши, а значения - объекты с ключами:
- "subject": строка, название предмета (или "Не указан") - "subject": строка, название предмета (или "Не указан") // Нормализуй subject ВЕРХ. РЕГИСТРОМ. и "С К Л Е И В А Й В О Т Т А К И Е" => "СКЛЕИВАЙ ВОТ ТАКИЕ" казусы
- "location": строка, аудитории через запятую (или "Не указана") - "location": строка, аудитории через запятую (или "Не указана")
- "teacher": строка, ФИО преподавателя(ей) через запятую (или "Не указан") - "teacher": строка, ФИО преподавателя(ей) через запятую (или "Не указан")
Типы локаций
ГУК - ГЛАВНЫЙ УЧЕБНЫЙ КОРПУС, если не указан корпус, пример: 400, значит речь идёт о гуке.
А - корпус А, пример: А-400б, А-404
Б - корпус Б, пример: Б-600
В - корпус В (высотка), пример: В-1203,
ЛК - корпус ЛК, пример: ЛК-100
Т - корпус на ВГТЗ (на тракторном), пример: Т-200
Корпус-Кабинет
Корпус всегда на кириллице, всегда пиши дифис. Если место СИЛЬНО отличается, например спорт. зал, то оставь как есть строку.
В входных данных забывают дефис, иногда пишут криво например: "А-400,404" - очевидно ты должен выдать две локации в корпусе А, составители просто ленивые
Если корпус ГУК, то префикса писать не надо, просто 100, 200, 400 и т.д.
Если в raw есть КИРОВСКИЙ то сделай префикс КИР-
Если ВГТЗ то Т-
Если Красноармейский то префикс КРАСН-
Верни ТОЛЬКО валидный JSON без форматирования markdown. Верни ТОЛЬКО валидный JSON без форматирования markdown.
Входные данные: Входные данные:
{json.dumps(batch, ensure_ascii=False)} {json.dumps(batch, ensure_ascii=False)}
@@ -113,105 +138,96 @@ def ask_gemini(unknown_raws):
"generationConfig": {"responseMimeType": "application/json"} "generationConfig": {"responseMimeType": "application/json"}
} }
try: while True: # Цикл ожидания при лимитах
resp = requests.post(url, json=payload, proxies=proxies, timeout=60) try:
resp.raise_for_status() resp = requests.post(url, json=payload, proxies=proxies, timeout=60*3)
data = resp.json() if resp.status_code == 429:
text_response = data['candidates'][0]['content']['parts'][0]['text'] log("[!] Лимит запросов Gemini (429). Ждем 60 секунд...")
time.sleep(60)
continue
parsed_batch = json.loads(text_response) resp.raise_for_status()
results.update(parsed_batch) parsed_res = json.loads(resp.json()['candidates'][0]['content']['parts'][0]['text'])
log(f"[+] Батч успешно обработан ИИ ({GEMINI_MODEL}).")
except requests.exceptions.HTTPError as e: # Сохраняем немедленно
error_body = e.response.text if e.response is not None else "Нет тела ответа" cache.update(parsed_res)
log(f"[!] HTTP Ошибка API Gemini ({e.response.status_code}): {error_body}") save_json(FILE_CACHE, cache)
except Exception as e: log(f"[+] Батч успешно сохранен в {FILE_CACHE}")
log(f"[!] Ошибка обращения к API Gemini: {e}") break # Успех, выходим из цикла ожидания
except Exception as e:
log(f"[!!!] Ошибка батча: {e}")
break # Фатальная ошибка батча, идем к следующему
return results 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(): def job_iteration():
log("--- Начало итерации обновления расписания ---") log("--- Начало итерации обновления расписания ---")
cache = load_json(FILE_CACHE, {}) cache = load_json(FILE_CACHE, {})
log(f"[*] Скачивание {URL_FACULTETS} ...")
try:
facultets_data = fetch_json_robust(URL_FACULTETS, timeout=120)
except Exception as e:
log(f"[!!!] Ошибка скачивания facultets.json: {e}")
return "error"
# ШАГ 1: Единоразовая загрузка монолита # ШАГ 1: Единоразовая загрузка монолита
log(f"[*] Скачивание all-in-one файла {URL_RESULT_V2} ...") log(f"[*] Скачивание all-in-one файла {URL_RESULT_V2} ...")
try: try:
v2_data = fetch_json_robust(URL_RESULT_V2, timeout=120) v2_data = fetch_json_robust(URL_RESULT_V2, timeout=120)
except Exception as e: except Exception as e:
log(f"[!!!] Ошибка скачивания базового файла расписаний: {e}") log(f"[!!!] Ошибка скачивания базового файла расписаний: {e}")
return return "error"
items_to_process = v2_data.get("all_files", []) all_files = v2_data.get("all_files", [])
if not items_to_process: if not all_files:
log("[!] Ключ 'all_files' пуст или отсутствует в result_v2.json") log("[!] Ключ 'all_files' пуст или отсутствует в result_v2.json")
return return
# ШАГ 2: Предварительный обход для сбора неизвестных ячеек (ОРИГИНАЛЬНАЯ ЛОГИКА ОБХОДА) # ШАГ 2: Предварительный обход для сбора неизвестных ячеек (ОРИГИНАЛЬНАЯ ЛОГИКА ОБХОДА)
unknown_raws = {} unknown_raws = {}
for file_data in items_to_process: def process_unknown_raw_events(pair_data):
sheets = file_data.get("sheets", {}) events = pair_data if isinstance(pair_data, list) else [pair_data]
for sheet_data in sheets.values(): for event in events:
for group_data in sheet_data.get("groups", {}).values(): if not isinstance(event, dict): continue
slots = group_data.get("slots", {})
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 excel_file_dict in all_files:
sheets = excel_file_dict.get("sheets", {})
for sheet_dict in sheets.values():
groups = sheet_dict.get("groups", {})
for group_dict in groups.values():
slots = group_dict.get("slots", {})
# slot_key = ПОНЕДЕЛЬНИК etc...
for slot_key, pair_value in slots.items(): for slot_key, pair_value in slots.items():
if not isinstance(pair_value, dict): continue if isinstance(pair_value, dict):
for pair_key, pair_data in pair_value.items(): is_event_object = pair_value.get("object", None) == 'event'
if not (isinstance(pair_key, str) and '-' in pair_key): continue if is_event_object:
process_unknown_raw_events(pair_value)
else:
for pair_key, pair_data in pair_value.items():
if not (isinstance(pair_key, str) and '-' in pair_key): continue
process_unknown_raw_events(pair_data)
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: Вызов ИИ (Только для новых строк) # ШАГ 3: Вызов ИИ (Только для новых строк)
if unknown_raws: if unknown_raws:
log(f"[*] Найдено {len(unknown_raws)} новых уникальных записей. Обращаемся к Gemini API...") log(f"[*] Найдено {len(unknown_raws)} новых уникальных записей. Обращаемся к Gemini API...")
ai_data = ask_gemini(unknown_raws) ai_data = ask_gemini(unknown_raws, cache)
if ai_data: if ai_data:
cache.update(ai_data) cache.update(ai_data)
save_json(FILE_CACHE, cache) save_json(FILE_CACHE, cache)
@@ -221,86 +237,114 @@ def job_iteration():
final_groups = {} final_groups = {}
excels_list = [] excels_list = []
log("[*] Сборка структуры слияния...") def process_event_for_group(group_key, event):
for file_data in items_to_process: if (isinstance(event, list)):
excel_info = file_data.get("excel", {}) for x in event:
sheets = file_data.get("sheets", {}) process_event_for_group(group_key, x)
return
file_ds_hash = excel_info.get("sha1hash", "UNKNOWN") is_event_object = False
try:
is_event_object = event.get("object", None) == 'event'
except Exception as e:
traceback.print_exception(e)
print(event)
if not is_event_object:
log("[WTF] process_event_for_group is not event object :<")
return
raw_list = event.get("raw", [])
slot_key = get_slot_key_for_event(event)
if slot_key not in final_groups[group_key]["slots"]:
final_groups[group_key]["slots"][slot_key] = {}
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 []
final_groups[group_key]["slots"][slot_key] = {
"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"),
"raw_hash": r_hash
}
log("[*] Сборка структуры слияния...")
for excel_file_dict in all_files:
excel = excel_file_dict.get("excel", {})
excel_url = excel['url']
excel_facultet = excel['facultet']
preferer_facultet = get_preferer_facultet(facultets_data, excel_url, skip_for=['asp', 'mag'])
group_prefix = get_abbrev_for_facultet(facultets_data, excel_facultet) + "/"
if preferer_facultet != excel_facultet and preferer_facultet is not None:
group_prefix = get_abbrev_for_facultet(facultets_data, preferer_facultet) + "/" + group_prefix
file_ds_hash = excel.get("sha1hash", "UNKNOWN")
sheets = excel_file_dict.get("sheets", {})
# Сборка метаданных экселя # Сборка метаданных экселя
excel_meta = { excel_meta = {
"data_source_hash": file_ds_hash, "data_source_hash": file_ds_hash,
"prefix_groups": group_prefix,
"facultet": excel_facultet,
"preferer_facultet": preferer_facultet,
"url": excel_url,
"week_keys_metadata": merge_week_keys_metadata(sheets) "week_keys_metadata": merge_week_keys_metadata(sheets)
} }
for k, v in excel_info.items(): for k, v in excel.items():
if k not in ["sheets", "data_source_hash", "week_keys_metadata"] and not isinstance(v, (dict, list)): if k not in ["sheets", "data_source_hash", "week_keys_metadata"] and not isinstance(v, (dict, list)):
excel_meta[k] = v excel_meta[k] = v
excels_list.append(excel_meta) excels_list.append(excel_meta)
# Обработка групп (ОРИГИНАЛЬНАЯ ЛОГИКА ОБХОДА) # Обработка групп (ОРИГИНАЛЬНАЯ ЛОГИКА ОБХОДА)
for sheet_data in sheets.values(): for sheet_dict in sheets.values():
groups = sheet_data.get("groups", {}) groups = sheet_dict.get("groups", {})
for group_id, group_data in groups.items(): for group_id, group_dict in groups.items():
group_name = group_data["name"] group_name = group_dict["name"]
group_key = (group_prefix + group_name.replace(" ", "")).upper()
if group_name not in final_groups: if group_key not in final_groups:
final_groups[group_name] = { final_groups[group_key] = {
"name": group_name, "name": group_name,
"facultet": excel_info.get('facultet', 'Неизвестно'), "facultet": excel_facultet,
"position": group_data.get("position"), "preferer_facultet": preferer_facultet,
"position_human": group_data.get("position_human"), "position": group_dict.get("position"),
"position_human": group_dict.get("position_human"),
"slots": {}, "slots": {},
"data_source_hash": file_ds_hash "data_source_hash": file_ds_hash
} }
else: else:
if file_ds_hash not in final_groups[group_name]["data_source_hash"]: if file_ds_hash not in final_groups[group_key]["data_source_hash"]:
final_groups[group_name]["data_source_hash"] += f",{file_ds_hash}" final_groups[group_key]["data_source_hash"] += f",{file_ds_hash}"
slots = group_data.get("slots", {}) slots = group_dict.get("slots", {})
# slot_key as ПОНЕДЕЛЬНИК_1 etc
for slot_key, pair_value in slots.items(): for slot_key, pair_value in slots.items():
if not isinstance(pair_value, dict): continue if isinstance(pair_value, dict):
is_event_object = pair_value.get("object", None) == 'event'
if is_event_object:
process_event_for_group(group_key, pair_value)
else:
for key, value in pair_value.items():
process_event_for_group(group_key, value)
if slot_key not in final_groups[group_name]["slots"]: elif isinstance(pair_value, list):
final_groups[group_name]["slots"][slot_key] = {} for xxx in pair_value:
process_event_for_group(group_key, xxx)
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 = { output = {
@@ -330,7 +374,7 @@ def job_iteration():
"excels": excels_list "excels": excels_list
} }
save_json(FILE_RESULT, output) save_json(FILE_RESULT, output, sort_keys=True)
log(f"[+] Сборка графа завершена. Уникальных групп: {len(final_groups)}. Файл {FILE_RESULT} обновлен.") log(f"[+] Сборка графа завершена. Уникальных групп: {len(final_groups)}. Файл {FILE_RESULT} обновлен.")
# ... Логика RabbitMQ остается без изменений ... # ... Логика RabbitMQ остается без изменений ...
@@ -365,14 +409,22 @@ def parser_loop():
if not GEMINI_API_KEY: log("[!] GEMINI_API_KEY не задан!") if not GEMINI_API_KEY: log("[!] GEMINI_API_KEY не задан!")
while True: while True:
devider = 1
try: try:
job_iteration() r = job_iteration()
if r == "error":
devider = 10
print("returned error; devider now = 10")
except Exception as e: except Exception as e:
log(f"[!!!] Критическая ошибка: {e}") log(f"[!!!] Критическая ошибка: {e}")
traceback.print_exc() traceback.print_exc()
devider = 5
log(f"devider now = 5")
log(f"[*] Ожидание {POLL_INTERVAL} сек...\n") delay = round(POLL_INTERVAL / devider)
time.sleep(POLL_INTERVAL) log(f"[*] Ожидание {delay} сек...\n")
time.sleep(delay)
if __name__ == "__main__": if __name__ == "__main__":
threading.Thread(target=parser_loop, daemon=True).start() threading.Thread(target=parser_loop, daemon=True).start()

62
utils.py Normal file
View File

@@ -0,0 +1,62 @@
from datetime import datetime
import hashlib
import json
import os
from requests.adapters import HTTPAdapter
from urllib3 import Retry
from consts import *
import requests
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, sort_keys=False):
with open(filename, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=sort_keys)
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()