changes
This commit is contained in:
3
.env
3
.env
@@ -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
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
data/
|
||||||
|
result.json
|
||||||
|
raw_cache.json
|
||||||
|
.env
|
||||||
|
__pycache__
|
||||||
23
consts.py
Normal file
23
consts.py
Normal 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))
|
||||||
15572
data/raw_cache.json
15572
data/raw_cache.json
File diff suppressed because it is too large
Load Diff
157404
data/result.json
157404
data/result.json
File diff suppressed because it is too large
Load Diff
13
httpserver.py
Normal file
13
httpserver.py
Normal 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
285
main-bad.py
Normal 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)
|
||||||
430
main.py
430
main.py
@@ -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)
|
||||||
parsed_batch = json.loads(text_response)
|
continue
|
||||||
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}")
|
cache.update(parsed_res)
|
||||||
except Exception as e:
|
save_json(FILE_CACHE, cache)
|
||||||
log(f"[!] Ошибка обращения к API Gemini: {e}")
|
log(f"[+] Батч успешно сохранен в {FILE_CACHE}")
|
||||||
|
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)
|
||||||
events = pair_data if isinstance(pair_data, list) else [pair_data]
|
else:
|
||||||
for event in events:
|
for pair_key, pair_data in pair_value.items():
|
||||||
if not isinstance(event, dict): continue
|
if not (isinstance(pair_key, str) and '-' in pair_key): continue
|
||||||
|
process_unknown_raw_events(pair_data)
|
||||||
|
|
||||||
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)
|
||||||
@@ -220,87 +236,115 @@ def job_iteration():
|
|||||||
# ШАГ 4: Сборка финального графа
|
# ШАГ 4: Сборка финального графа
|
||||||
final_groups = {}
|
final_groups = {}
|
||||||
excels_list = []
|
excels_list = []
|
||||||
|
|
||||||
|
def process_event_for_group(group_key, event):
|
||||||
|
if (isinstance(event, list)):
|
||||||
|
for x in event:
|
||||||
|
process_event_for_group(group_key, x)
|
||||||
|
return
|
||||||
|
|
||||||
|
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("[*] Сборка структуры слияния...")
|
log("[*] Сборка структуры слияния...")
|
||||||
for file_data in items_to_process:
|
for excel_file_dict in all_files:
|
||||||
excel_info = file_data.get("excel", {})
|
excel = excel_file_dict.get("excel", {})
|
||||||
sheets = file_data.get("sheets", {})
|
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_info.get("sha1hash", "UNKNOWN")
|
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 slot_key not in final_groups[group_name]["slots"]:
|
if is_event_object:
|
||||||
final_groups[group_name]["slots"][slot_key] = {}
|
process_event_for_group(group_key, pair_value)
|
||||||
|
else:
|
||||||
|
for key, value in pair_value.items():
|
||||||
|
process_event_for_group(group_key, value)
|
||||||
|
|
||||||
target_slot = final_groups[group_name]["slots"][slot_key]
|
elif isinstance(pair_value, list):
|
||||||
|
for xxx in pair_value:
|
||||||
for pair_key, pair_data in pair_value.items():
|
process_event_for_group(group_key, xxx)
|
||||||
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
62
utils.py
Normal 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()
|
||||||
Reference in New Issue
Block a user