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 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))
# =================================================
import consts
import httpserver
from utils import *
from consts import *
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)
app.include_router(httpserver.router)
def merge_week_keys_metadata(sheets):
merged = {}
@@ -81,7 +33,64 @@ def merge_week_keys_metadata(sheets):
merged[day][month].append(d)
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:
log("[!] GEMINI_API_KEY не задан! ИИ-парсинг пропущен.")
return {}
@@ -98,10 +107,26 @@ def ask_gemini(unknown_raws):
prompt = f"""
Ты парсер академического расписания. Я даю тебе JSON, где ключи - хэши, а значения - массив сырых строк расписания.
Твоя задача вернуть JSON, где ключи - те же хэши, а значения - объекты с ключами:
- "subject": строка, название предмета (или "Не указан")
- "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)}
@@ -113,105 +138,96 @@ def ask_gemini(unknown_raws):
"generationConfig": {"responseMimeType": "application/json"}
}
try:
resp = requests.post(url, json=payload, proxies=proxies, timeout=60)
resp.raise_for_status()
while True: # Цикл ожидания при лимитах
try:
resp = requests.post(url, json=payload, proxies=proxies, timeout=60*3)
data = resp.json()
text_response = data['candidates'][0]['content']['parts'][0]['text']
if resp.status_code == 429:
log("[!] Лимит запросов Gemini (429). Ждем 60 секунд...")
time.sleep(60)
continue
parsed_batch = json.loads(text_response)
results.update(parsed_batch)
log(f"[+] Батч успешно обработан ИИ ({GEMINI_MODEL}).")
resp.raise_for_status()
parsed_res = json.loads(resp.json()['candidates'][0]['content']['parts'][0]['text'])
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}")
# Сохраняем немедленно
cache.update(parsed_res)
save_json(FILE_CACHE, cache)
log(f"[+] Батч успешно сохранен в {FILE_CACHE}")
break # Успех, выходим из цикла ожидания
except Exception as e:
log(f"[!!!] Ошибка батча: {e}")
break # Фатальная ошибка батча, идем к следующему
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, {})
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: Единоразовая загрузка монолита
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
return "error"
items_to_process = v2_data.get("all_files", [])
if not items_to_process:
all_files = v2_data.get("all_files", [])
if not all_files:
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", {})
def 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
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():
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
if isinstance(pair_value, dict):
is_event_object = pair_value.get("object", None) == 'event'
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: Вызов ИИ (Только для новых строк)
if unknown_raws:
log(f"[*] Найдено {len(unknown_raws)} новых уникальных записей. Обращаемся к Gemini API...")
ai_data = ask_gemini(unknown_raws)
ai_data = ask_gemini(unknown_raws, cache)
if ai_data:
cache.update(ai_data)
save_json(FILE_CACHE, cache)
@@ -221,86 +237,114 @@ def job_iteration():
final_groups = {}
excels_list = []
log("[*] Сборка структуры слияния...")
for file_data in items_to_process:
excel_info = file_data.get("excel", {})
sheets = file_data.get("sheets", {})
def process_event_for_group(group_key, event):
if (isinstance(event, list)):
for x in event:
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 = {
"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)
}
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)):
excel_meta[k] = v
excels_list.append(excel_meta)
# Обработка групп (ОРИГИНАЛЬНАЯ ЛОГИКА ОБХОДА)
for sheet_data in sheets.values():
groups = sheet_data.get("groups", {})
for sheet_dict in sheets.values():
groups = sheet_dict.get("groups", {})
for group_id, group_data in groups.items():
group_name = group_data["name"]
for group_id, group_dict in groups.items():
group_name = group_dict["name"]
group_key = (group_prefix + group_name.replace(" ", "")).upper()
if group_name not in final_groups:
final_groups[group_name] = {
if group_key not in final_groups:
final_groups[group_key] = {
"name": group_name,
"facultet": excel_info.get('facultet', 'Неизвестно'),
"position": group_data.get("position"),
"position_human": group_data.get("position_human"),
"facultet": excel_facultet,
"preferer_facultet": preferer_facultet,
"position": group_dict.get("position"),
"position_human": group_dict.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}"
if file_ds_hash not in final_groups[group_key]["data_source_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():
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"]:
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")
}
elif isinstance(pair_value, list):
for xxx in pair_value:
process_event_for_group(group_key, xxx)
# Формируем финальный результат ТОЧНО в первоначальном виде
output = {
@@ -330,7 +374,7 @@ def job_iteration():
"excels": excels_list
}
save_json(FILE_RESULT, output)
save_json(FILE_RESULT, output, sort_keys=True)
log(f"[+] Сборка графа завершена. Уникальных групп: {len(final_groups)}. Файл {FILE_RESULT} обновлен.")
# ... Логика RabbitMQ остается без изменений ...
@@ -365,14 +409,22 @@ def parser_loop():
if not GEMINI_API_KEY: log("[!] GEMINI_API_KEY не задан!")
while True:
devider = 1
try:
job_iteration()
r = job_iteration()
if r == "error":
devider = 10
print("returned error; devider now = 10")
except Exception as e:
log(f"[!!!] Критическая ошибка: {e}")
traceback.print_exc()
devider = 5
log(f"devider now = 5")
log(f"[*] Ожидание {POLL_INTERVAL} сек...\n")
time.sleep(POLL_INTERVAL)
delay = round(POLL_INTERVAL / devider)
log(f"[*] Ожидание {delay} сек...\n")
time.sleep(delay)
if __name__ == "__main__":
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()