save
This commit is contained in:
115
main.py
115
main.py
@@ -6,8 +6,10 @@ import time
|
|||||||
import traceback
|
import traceback
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
# --- Загрузка переменных окружения ---
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
# -----------------------------------
|
||||||
|
|
||||||
# ================= КОНФИГУРАЦИЯ =================
|
# ================= КОНФИГУРАЦИЯ =================
|
||||||
URL_PARSER_ROOT = "https://fazziclay.com/api/v1/vstu_schedule_parser_v2/parser.json"
|
URL_PARSER_ROOT = "https://fazziclay.com/api/v1/vstu_schedule_parser_v2/parser.json"
|
||||||
@@ -15,10 +17,11 @@ BASE_URL_FILES = "https://fazziclay.com/api/v1/vstu_schedule_parser_v2/parsed/"
|
|||||||
FILE_RESULT = "result.json"
|
FILE_RESULT = "result.json"
|
||||||
FILE_CACHE = "raw_cache.json"
|
FILE_CACHE = "raw_cache.json"
|
||||||
|
|
||||||
# Настройки для Docker / Окружения
|
GEMINI_MODEL="gemini-flash-latest"
|
||||||
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", 600)) # По умолчанию 10 минут
|
|
||||||
|
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", 600))
|
||||||
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY")
|
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY")
|
||||||
PROXY_URL = os.environ.get("PROXY_URL") # Например: socks5://user:pass@127.0.0.1:1080
|
PROXY_URL = os.environ.get("PROXY_URL")
|
||||||
# =================================================
|
# =================================================
|
||||||
|
|
||||||
def log(msg):
|
def log(msg):
|
||||||
@@ -40,20 +43,36 @@ def save_json(filename, data):
|
|||||||
with open(filename, 'w', encoding='utf-8') as f:
|
with open(filename, 'w', encoding='utf-8') as f:
|
||||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
def merge_week_keys_metadata(sheets):
|
||||||
|
"""Объединяет week_keys_metadata со всех листов, исключая дубликаты дней."""
|
||||||
|
merged = {}
|
||||||
|
for sheet_data in sheets.values():
|
||||||
|
wkm = sheet_data.get("week_keys_metadata", {})
|
||||||
|
for day, months in wkm.items():
|
||||||
|
if day not in merged:
|
||||||
|
merged[day] = {}
|
||||||
|
for month, days in months.items():
|
||||||
|
if month not in merged[day]:
|
||||||
|
merged[day][month] =[]
|
||||||
|
# Добавляем даты, сохраняя порядок и избегая дублей
|
||||||
|
for d in days:
|
||||||
|
if d not in merged[day][month]:
|
||||||
|
merged[day][month].append(d)
|
||||||
|
return merged
|
||||||
|
|
||||||
def ask_gemini(unknown_raws):
|
def ask_gemini(unknown_raws):
|
||||||
"""Отправляет неизвестные записи в Gemini API для разбора."""
|
|
||||||
if not GEMINI_API_KEY:
|
if not GEMINI_API_KEY:
|
||||||
log("[!] GEMINI_API_KEY не задан! ИИ-парсинг пропущен.")
|
log("[!] GEMINI_API_KEY не задан! ИИ-парсинг пропущен.")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
proxies = {}
|
proxies = {}
|
||||||
if PROXY_URL:
|
if PROXY_URL:
|
||||||
# requests автоматически перенаправит трафик через proxy
|
log("Using proxy")
|
||||||
proxies = {"http": PROXY_URL, "https": PROXY_URL}
|
proxies = {"http": PROXY_URL, "https": PROXY_URL}
|
||||||
|
|
||||||
results = {}
|
results = {}
|
||||||
items = list(unknown_raws.items())
|
items = list(unknown_raws.items())
|
||||||
batch_size = 40 # Разбиваем на батчи, чтобы не перегрузить лимиты вывода ИИ
|
batch_size = 40
|
||||||
|
|
||||||
for i in range(0, len(items), batch_size):
|
for i in range(0, len(items), batch_size):
|
||||||
batch = dict(items[i:i+batch_size])
|
batch = dict(items[i:i+batch_size])
|
||||||
@@ -71,34 +90,45 @@ def ask_gemini(unknown_raws):
|
|||||||
{json.dumps(batch, ensure_ascii=False)}
|
{json.dumps(batch, ensure_ascii=False)}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key={GEMINI_API_KEY}"
|
url = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_MODEL}:generateContent?key={GEMINI_API_KEY}"
|
||||||
payload = {
|
payload = {
|
||||||
"contents": [{"parts":[{"text": prompt}]}],
|
"contents": [{
|
||||||
|
"role": "user",
|
||||||
|
"parts": [{"text": prompt}]
|
||||||
|
}],
|
||||||
"generationConfig": {
|
"generationConfig": {
|
||||||
"response_mime_type": "application/json" # Заставляем Gemini вернуть строго JSON
|
"responseMimeType": "application/json" # ИСПРАВЛЕНИЕ: строго camelCase для REST API
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = requests.post(url, json=payload, proxies=proxies, timeout=60)
|
resp = requests.post(url, json=payload, proxies=proxies, timeout=60)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status() # Выкинет ошибку для 400, 403, 404 и т.д.
|
||||||
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
text_response = data['candidates'][0]['content']['parts'][0]['text']
|
text_response = data['candidates'][0]['content']['parts'][0]['text']
|
||||||
|
|
||||||
parsed_batch = json.loads(text_response)
|
parsed_batch = json.loads(text_response)
|
||||||
results.update(parsed_batch)
|
results.update(parsed_batch)
|
||||||
log(f"[+] Батч успешно обработан ИИ.")
|
log(f"[+] Батч успешно обработан ИИ ({GEMINI_MODEL}).")
|
||||||
|
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
# ИСПРАВЛЕНИЕ: Читаем текст ответа от Google (там написана причина)
|
||||||
|
error_body = e.response.text if e.response is not None else "Нет тела ответа"
|
||||||
|
|
||||||
|
if e.response.status_code == 404:
|
||||||
|
log(f"[!] Ошибка 404: Модель '{GEMINI_MODEL}' не найдена. Обновите GEMINI_MODEL в .env!")
|
||||||
|
elif e.response.status_code == 400:
|
||||||
|
log(f"[!] Ошибка 400 (Bad Request): Неверный формат запроса. Ответ Google:\n{error_body}")
|
||||||
|
else:
|
||||||
|
log(f"[!] HTTP Ошибка API Gemini: {e}\nОтвет: {error_body}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"[!] Ошибка API Gemini в батче: {e}")
|
log(f"[!] Ошибка обращения к API Gemini: {e}")
|
||||||
# Не останавливаем процесс, просто вернем те результаты, которые успели распарситься.
|
|
||||||
# Ошибочные записи отправятся при следующем цикле через 10 минут.
|
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def fetch_and_build(cache):
|
def fetch_and_build(cache):
|
||||||
"""Основной этап скачивания файлов V2 и построения структуры."""
|
|
||||||
log("[*] Загрузка списка файлов парсера V2...")
|
log("[*] Загрузка списка файлов парсера V2...")
|
||||||
parser_data = requests.get(URL_PARSER_ROOT, timeout=30).json()
|
parser_data = requests.get(URL_PARSER_ROOT, timeout=30).json()
|
||||||
|
|
||||||
@@ -116,14 +146,22 @@ def fetch_and_build(cache):
|
|||||||
log(f"[!] Ошибка скачивания {file_url}: {e}")
|
log(f"[!] Ошибка скачивания {file_url}: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# В-третьих: Добавляем копию excel без 'sheets'
|
file_ds_hash = faculty_data.get("sha1hash", faculty_data.get("excel", {}).get("sha1hash", "UNKNOWN"))
|
||||||
excel_meta = {k: v for k, v in faculty_data.items() if k != 'sheets'}
|
|
||||||
excels_list.append(excel_meta)
|
|
||||||
|
|
||||||
# Получаем data_source_hash из файла excel
|
|
||||||
file_ds_hash = faculty_data.get("data_source_hash", faculty_data.get("excel", {}).get("data_source_hash", "UNKNOWN"))
|
|
||||||
|
|
||||||
sheets = faculty_data.get("sheets", {})
|
sheets = faculty_data.get("sheets", {})
|
||||||
|
|
||||||
|
# --- СБОРКА ОБЪЕКТА EXCEL (Обновлено) ---
|
||||||
|
excel_meta = {
|
||||||
|
"data_source_hash": file_ds_hash,
|
||||||
|
"week_keys_metadata": merge_week_keys_metadata(sheets)
|
||||||
|
}
|
||||||
|
# Переносим остальные корневые ключи из оригинального json эксельки (за исключением уже обработанных)
|
||||||
|
for k, v in faculty_data.items():
|
||||||
|
if k not in ["sheets", "data_source_hash", "week_keys_metadata"]:
|
||||||
|
excel_meta[k] = v
|
||||||
|
|
||||||
|
excels_list.append(excel_meta)
|
||||||
|
# ----------------------------------------
|
||||||
|
|
||||||
for sheet_data in sheets.values():
|
for sheet_data in sheets.values():
|
||||||
groups = sheet_data.get("groups", {})
|
groups = sheet_data.get("groups", {})
|
||||||
|
|
||||||
@@ -135,7 +173,7 @@ def fetch_and_build(cache):
|
|||||||
"position": group_data.get("position"),
|
"position": group_data.get("position"),
|
||||||
"position_human": group_data.get("position_human"),
|
"position_human": group_data.get("position_human"),
|
||||||
"slots": {},
|
"slots": {},
|
||||||
"data_source_hash": file_ds_hash # ИЗМЕНЕНИЕ: Берем хэш из метаданных экселя
|
"data_source_hash": file_ds_hash
|
||||||
}
|
}
|
||||||
|
|
||||||
slots = group_data.get("slots", {})
|
slots = group_data.get("slots", {})
|
||||||
@@ -148,7 +186,7 @@ def fetch_and_build(cache):
|
|||||||
for pair_key, pair_data in pair_value.items():
|
for pair_key, pair_data in pair_value.items():
|
||||||
if not (isinstance(pair_key, str) and '-' in pair_key): continue
|
if not (isinstance(pair_key, str) and '-' in pair_key): continue
|
||||||
|
|
||||||
events = pair_data if isinstance(pair_data, list) else [pair_data]
|
events = pair_data if isinstance(pair_data, list) else[pair_data]
|
||||||
|
|
||||||
for i, event in enumerate(events):
|
for i, event in enumerate(events):
|
||||||
current_pair_id = pair_key if i == 0 else f"{pair_key}_{i}"
|
current_pair_id = pair_key if i == 0 else f"{pair_key}_{i}"
|
||||||
@@ -159,8 +197,8 @@ def fetch_and_build(cache):
|
|||||||
|
|
||||||
if r_hash in cache:
|
if r_hash in cache:
|
||||||
res = cache[r_hash]
|
res = cache[r_hash]
|
||||||
locs =[l.strip() for l in res.get("location", "").split(",")] if res.get("location") and res.get("location") not in ["Не указана", "Не указан"] else []
|
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[]
|
leads =[l.strip() for l in res.get("teacher", "").split(",")] if res.get("teacher") and res.get("teacher") not in["Не указана", "Не указан"] else []
|
||||||
|
|
||||||
final_groups[group_data["name"]]["slots"][slot_key][current_pair_id] = {
|
final_groups[group_data["name"]]["slots"][slot_key][current_pair_id] = {
|
||||||
"discipline_name": res.get("subject", "Не указан"),
|
"discipline_name": res.get("subject", "Не указан"),
|
||||||
@@ -180,7 +218,6 @@ def fetch_and_build(cache):
|
|||||||
return final_groups, excels_list, unknown_raws
|
return final_groups, excels_list, unknown_raws
|
||||||
|
|
||||||
def job_iteration():
|
def job_iteration():
|
||||||
"""Выполнение одной итерации обновления расписания."""
|
|
||||||
log("--- Начало итерации обновления расписания ---")
|
log("--- Начало итерации обновления расписания ---")
|
||||||
cache = load_json(FILE_CACHE, {})
|
cache = load_json(FILE_CACHE, {})
|
||||||
|
|
||||||
@@ -203,6 +240,27 @@ def job_iteration():
|
|||||||
|
|
||||||
# Формируем финальный результат
|
# Формируем финальный результат
|
||||||
output = {
|
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()),
|
"actual_at": int(datetime.now().timestamp()),
|
||||||
"groups": final_groups,
|
"groups": final_groups,
|
||||||
"excels": excels_list
|
"excels": excels_list
|
||||||
@@ -219,7 +277,6 @@ def main():
|
|||||||
if not GEMINI_API_KEY:
|
if not GEMINI_API_KEY:
|
||||||
log("[!] ВНИМАНИЕ: GEMINI_API_KEY не задан. Интеллектуальный парсинг работать не будет!")
|
log("[!] ВНИМАНИЕ: GEMINI_API_KEY не задан. Интеллектуальный парсинг работать не будет!")
|
||||||
|
|
||||||
# Бесконечный цикл - требование №1
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
job_iteration()
|
job_iteration()
|
||||||
|
|||||||
@@ -15303,5 +15303,25 @@
|
|||||||
"subject": "АВТОМАТИЗ. ЭЛЕКТРОПРИВОД, МОДЕЛ. ПРОЦЕССОВ И СИСТЕМ (лаб. 4 час.)",
|
"subject": "АВТОМАТИЗ. ЭЛЕКТРОПРИВОД, МОДЕЛ. ПРОЦЕССОВ И СИСТЕМ (лаб. 4 час.)",
|
||||||
"teacher": "Ветлицын М.Ю., Нелюбова А.В.",
|
"teacher": "Ветлицын М.Ю., Нелюбова А.В.",
|
||||||
"location": "Б 308, Б 310"
|
"location": "Б 308, Б 310"
|
||||||
|
},
|
||||||
|
"a0e2952aa9203a212f6359dfec331829bd009d95": {
|
||||||
|
"subject": "И Н. Я З Ы К, нем.",
|
||||||
|
"location": "121",
|
||||||
|
"teacher": "Чечет"
|
||||||
|
},
|
||||||
|
"3ce8b8e9ecefab0f4f62b3edb0594800fc466c66": {
|
||||||
|
"subject": "ИНОСТР. ЯЗЫК, нем.",
|
||||||
|
"location": "124",
|
||||||
|
"teacher": "Чечет"
|
||||||
|
},
|
||||||
|
"0c598d43bf5340b2814773005d827b73b1edad7a": {
|
||||||
|
"subject": "ЭКОНОМИКА ОТРАСЛИ",
|
||||||
|
"location": "В-1004",
|
||||||
|
"teacher": "Иванюк"
|
||||||
|
},
|
||||||
|
"b1acdcf1e47ef1d2826e58865face9c230efc5d0": {
|
||||||
|
"subject": "МОДЕЛИРОВАНИЕ СИСТЕМ",
|
||||||
|
"location": "В-403",
|
||||||
|
"teacher": "Казьмин И.В."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
17552
result.json
17552
result.json
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user