refactor: big refactor
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,3 +5,5 @@ __pycache__
|
|||||||
result*.json
|
result*.json
|
||||||
groups.json
|
groups.json
|
||||||
diffable_dates.txt
|
diffable_dates.txt
|
||||||
|
parsed
|
||||||
|
parser.json
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY . .
|
||||||
|
CMD ["python", "-u", "main.py"]
|
||||||
201
aigenerated.py
201
aigenerated.py
@@ -1,201 +0,0 @@
|
|||||||
# Copyright GEMINI
|
|
||||||
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
# --- Ресурсы для алгоритма ---
|
|
||||||
STOP_WORDS = {'пр.', 'лек.', 'лаб.', 'семинар'}
|
|
||||||
TITLES = ('доц.', 'проф.', 'асс.', 'ст.пр.')
|
|
||||||
SURNAME_ENDINGS = ('ов', 'ев', 'ин', 'ский', 'цкой', 'их', 'ых', 'ова', 'ева', 'ина', 'ская', 'ян', 'ко', "ня", "ин")
|
|
||||||
|
|
||||||
def is_surname_string(s: str) -> bool:
|
|
||||||
"""
|
|
||||||
Классифицирует строку, определяя, содержит ли она фамилию.
|
|
||||||
"""
|
|
||||||
if not isinstance(s, str) or not s:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Шаг 1: Очистка
|
|
||||||
s_clean = s.strip()
|
|
||||||
|
|
||||||
# Шаг 2: Жесткие правила "НЕ ФАМИЛИЯ"
|
|
||||||
if s_clean.lower() in STOP_WORDS:
|
|
||||||
return False
|
|
||||||
if s_clean.isupper() and len(s_clean) > 3:
|
|
||||||
return False
|
|
||||||
if re.search(r'[\(\)/«»%]', s_clean):
|
|
||||||
return False
|
|
||||||
|
|
||||||
words = s_clean.split()
|
|
||||||
if len(words) > 3:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Шаг 3: Жесткие правила "ТОЧНО ФАМИЛИЯ"
|
|
||||||
if re.search(r'\b[А-Я]\.?', s_clean): # Ищет "И.А." или "И.А"
|
|
||||||
return True
|
|
||||||
if s_clean.lower().startswith(TITLES):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Шаг 4: Эвристический анализ для оставшихся случаев
|
|
||||||
score = 0
|
|
||||||
|
|
||||||
# Правило на капитализацию
|
|
||||||
if words and words[0][0].isupper():
|
|
||||||
score += 5
|
|
||||||
else:
|
|
||||||
# Если слово не с большой буквы, это почти точно не фамилия
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Правило на окончания
|
|
||||||
last_word = words[-1].lower()
|
|
||||||
if last_word.endswith(SURNAME_ENDINGS):
|
|
||||||
score += 6
|
|
||||||
|
|
||||||
# Правило на количество слов
|
|
||||||
if len(words) in [1, 2]:
|
|
||||||
score += 2
|
|
||||||
|
|
||||||
# Пороговое значение
|
|
||||||
THRESHOLD = 8
|
|
||||||
return score >= THRESHOLD
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def extract_last_name(name_str: str) -> str or None:
|
|
||||||
"""
|
|
||||||
Извлекает из строки только фамилию.
|
|
||||||
|
|
||||||
Справляется с приклеенными званиями (типа "ст.пр.Дмитриев") и отбрасывает инициалы.
|
|
||||||
Ищет первое слово, написанное с заглавной буквы.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name_str: Исходная "грязная" строка с именем.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Чистая фамилия в виде строки, или None, если фамилия не найдена.
|
|
||||||
"""
|
|
||||||
# Проверка, что на вход подана строка
|
|
||||||
if not isinstance(name_str, str):
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Паттерн для поиска:
|
|
||||||
# [А-ЯЁ] - одна заглавная русская буква в начале
|
|
||||||
# [а-яё]+ - одна или более строчных русских букв после неё
|
|
||||||
# (?:-[А-ЯЁ][а-яё]+)? - опциональная часть для двойных фамилий (например, -Петров)
|
|
||||||
pattern = r'[А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)?'
|
|
||||||
|
|
||||||
match = re.search(pattern, name_str)
|
|
||||||
|
|
||||||
if match:
|
|
||||||
return match.group(0) # Возвращаем найденное совпадение
|
|
||||||
else:
|
|
||||||
return None # Если ничего не найдено
|
|
||||||
|
|
||||||
|
|
||||||
# --- Шаг 0: Константы ---
|
|
||||||
POSITIVE_KEYWORDS = ['зал', 'ауд', 'каб', 'корп', 'кор.', "ЛК"]
|
|
||||||
NEGATIVE_KEYWORDS = ['доц', 'проф', 'асс', 'лек', 'пр']
|
|
||||||
|
|
||||||
# Транслитерация для унификации
|
|
||||||
TRANS_TABLE = str.maketrans('TCBAHIMK', 'ТКВАНІМК') # Латиница -> Кириллица
|
|
||||||
|
|
||||||
def is_room_number(s: str) -> bool:
|
|
||||||
"""
|
|
||||||
Проверяет, является ли строка номером кабинета, по многоуровневому алгоритму.
|
|
||||||
"""
|
|
||||||
# --- Шаг 1: Быстрые негативные фильтры ---
|
|
||||||
if not isinstance(s, str) or not s.strip():
|
|
||||||
return False # 1. Проверка на пустоту
|
|
||||||
|
|
||||||
if ',' in s:
|
|
||||||
return False # 2. Проверка на запятые (даты)
|
|
||||||
|
|
||||||
# 3. Проверка на "очевидный мусор"
|
|
||||||
first_word = s.strip().lower().split()[0]
|
|
||||||
if first_word in NEGATIVE_KEYWORDS:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 4. Проверка на инициалы (напр. А.Е.)
|
|
||||||
if re.search(r'\b[А-Я]\.[А-Я]\.?\b', s):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 5. Проверка на длинное слово без цифр
|
|
||||||
if not any(char.isdigit() for char in s) and len(s.split()) == 1 and len(s) > 10:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# --- Шаг 2: Быстрые позитивные фильтры ---
|
|
||||||
s_lower = s.lower()
|
|
||||||
if any(keyword in s_lower for keyword in POSITIVE_KEYWORDS):
|
|
||||||
return True # 1. Проверка по ключевым словам
|
|
||||||
|
|
||||||
# --- Шаг 3: Основной анализ ---
|
|
||||||
if not any(char.isdigit() for char in s):
|
|
||||||
return False # 1. Требуется наличие цифр
|
|
||||||
|
|
||||||
# 2. Создание "чистой" версии
|
|
||||||
clean_s = s.upper().translate(TRANS_TABLE).replace(' ', '')
|
|
||||||
|
|
||||||
# 3. Комплексный паттерн для проверки
|
|
||||||
# Пояснение:
|
|
||||||
# ^...$ - шаблон должен соответствовать всей строке
|
|
||||||
# [А-ЯЁ]?-? - необязательная буква и необязательный дефис в начале
|
|
||||||
# \d+ - одна или более цифр (ядро номера)
|
|
||||||
# (?:[.-]\d+)* - необязательные группы ".число" или "-число"
|
|
||||||
# [А-ЯЁ]?$ - необязательная буква в конце
|
|
||||||
room_pattern = re.compile(r'^[А-ЯЁ]?-?\d+(?:[.-]\d+)*[А-ЯЁ]?$')
|
|
||||||
|
|
||||||
if room_pattern.fullmatch(clean_s):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# --- Шаг 4: Финальное решение ---
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from urllib.parse import urlsplit, urlunsplit, quote
|
|
||||||
|
|
||||||
def download_file_from_url(url, output_filename):
|
|
||||||
"""
|
|
||||||
Скачивает файл по URL со спецсимволами и пробелами, сохраняя его под указанным именем.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url (str): Исходный URL, который может содержать пробелы и кириллицу.
|
|
||||||
output_filename (str): Имя файла для сохранения (например, 'calc.xls').
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# --- Шаг 1: Правильное кодирование URL ---
|
|
||||||
# Разбираем URL на части: ('https', 'www.vstu.ru', '/path/to file.xls', '', '')
|
|
||||||
parts = urlsplit(url)
|
|
||||||
|
|
||||||
# Кодируем только путь, оставляя слэши '/' безопасными
|
|
||||||
# Это превратит ' ' в '%20', 'В' в '%D0%92' и т.д.
|
|
||||||
encoded_path = quote(parts.path, safe='/-_')
|
|
||||||
|
|
||||||
# Собираем URL обратно из частей с уже закодированным путем
|
|
||||||
encoded_url = urlunsplit((parts.scheme, parts.netloc, encoded_path, parts.query, parts.fragment))
|
|
||||||
|
|
||||||
|
|
||||||
# --- Шаг 2: Скачивание файла ---
|
|
||||||
response = requests.get(encoded_url, stream=True)
|
|
||||||
|
|
||||||
# Проверяем, успешен ли запрос (код 200 OK)
|
|
||||||
# Если сервер вернет ошибку (404, 500 и т.д.), здесь возникнет исключение
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
# --- Шаг 3: Сохранение файла ---
|
|
||||||
# Открываем файл для записи в бинарном режиме ('wb')
|
|
||||||
# Использование 'with' гарантирует, что файл будет закрыт автоматически
|
|
||||||
with open(output_filename, 'wb') as f:
|
|
||||||
for chunk in response.iter_content(chunk_size=8192):
|
|
||||||
f.write(chunk)
|
|
||||||
|
|
||||||
print(f"✅ Файл успешно скачан и сохранен как '{output_filename}'")
|
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
print(f"❌ Ошибка скачивания: {e}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Произошла непредвиденная ошибка: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
34
hashes.py
34
hashes.py
@@ -1,34 +0,0 @@
|
|||||||
import hashlib
|
|
||||||
|
|
||||||
def calculate_sha1(filepath):
|
|
||||||
"""
|
|
||||||
Calculates the SHA1 hash of a given file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filepath (str): The path to the file.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: The hexadecimal representation of the SHA1 hash, or None if the file is not found.
|
|
||||||
"""
|
|
||||||
sha1_hash = hashlib.sha1()
|
|
||||||
try:
|
|
||||||
with open(filepath, "rb") as f:
|
|
||||||
# Read the file in chunks to handle large files efficiently
|
|
||||||
for chunk in iter(lambda: f.read(4096), b""):
|
|
||||||
sha1_hash.update(chunk)
|
|
||||||
return sha1_hash.hexdigest()
|
|
||||||
except FileNotFoundError:
|
|
||||||
print(f"Error: File not found at {filepath}")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
print(f"An error occurred: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# Example usage:
|
|
||||||
file_path = "xls.xls" # Replace with the actual path to your file
|
|
||||||
sha1_result = calculate_sha1(file_path)
|
|
||||||
|
|
||||||
if sha1_result:
|
|
||||||
print(f"The SHA1 hash of '{file_path}' is: {sha1_result}")
|
|
||||||
@@ -51,15 +51,8 @@ def parse_links(facultets):
|
|||||||
"url": url,
|
"url": url,
|
||||||
"last_changed": last_changed
|
"last_changed": last_changed
|
||||||
}
|
}
|
||||||
print(record)
|
print("Found in vstu.ru: ", record)
|
||||||
EXCEL_LINKS.append(record)
|
EXCEL_LINKS.append(record)
|
||||||
|
|
||||||
return sorted(EXCEL_LINKS, key=lambda x: x['url'])
|
return sorted(EXCEL_LINKS, key=lambda x: x['url'])
|
||||||
|
|
||||||
|
|
||||||
def excels_to_diffabledates(excels):
|
|
||||||
dates = []
|
|
||||||
for excel in excels:
|
|
||||||
dates.append(f"{excel['last_changed']} {excel['facultet']} {excel['url']}")
|
|
||||||
|
|
||||||
return "\n".join(sorted(dates)).strip()
|
|
||||||
|
|||||||
274
main.py
274
main.py
@@ -9,14 +9,12 @@ import random
|
|||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
import aigenerated
|
|
||||||
import parser
|
import parser
|
||||||
import translations
|
import translations
|
||||||
import utils
|
import utils
|
||||||
import json
|
import json
|
||||||
import links_parser
|
import links_parser
|
||||||
import shutil
|
import shutil
|
||||||
import hashes
|
|
||||||
|
|
||||||
def currt():
|
def currt():
|
||||||
return round(time.time())
|
return round(time.time())
|
||||||
@@ -25,118 +23,39 @@ FACULTETS = sorted([
|
|||||||
"asp", "mag", "fastiv", "fat", "ftkm", "ftpp", "feu", "fevt", "htf", "vkf", "mmf", "fpik"
|
"asp", "mag", "fastiv", "fat", "ftkm", "ftpp", "feu", "fevt", "htf", "vkf", "mmf", "fpik"
|
||||||
])
|
])
|
||||||
DIRNAME = "excels"
|
DIRNAME = "excels"
|
||||||
DIFFABLE_DATES = "diffable_dates.txt"
|
PARSED_DIR = "parsed"
|
||||||
|
|
||||||
SKIP_DIFFABLE_DATES = True
|
|
||||||
|
|
||||||
DEBUG_ONE_FAC = None #'fevt'
|
DEBUG_ONE_FAC = None #'fevt'
|
||||||
LOGGING = False
|
|
||||||
|
|
||||||
unique_raws = set()
|
parser.LOGGING = LOGGING = False
|
||||||
result = {
|
|
||||||
"version": 1,
|
|
||||||
"notice": "ОТКАЗ ОТ ОТВЕТСТВЕННОСТИ: Данные, доступ к API и т.д. предоставляется КАК-ЕСТЬ (AS-IS) без каких либо, явно или не явно подразумеваемых гарантий.\n\nПарсер написал: Миронов Станислав\n\nИсточник данных: https://www.vstu.ru/student/raspisaniya/zanyatiy/index.php",
|
|
||||||
"actual_at": round(time.time()),
|
|
||||||
"documentation": "https://fazziclay.com/api/v1/vstu_schedule_parser/scheme.json (temporary outdated)",
|
|
||||||
"daypicture": "0w0",
|
|
||||||
"daycite": "KIlLSWITCH",
|
|
||||||
"contact": "https://fazziclay.com/",
|
|
||||||
"university": "VSTU",
|
|
||||||
"university_site": "https://www.vstu.ru/",
|
|
||||||
"source": "https://fazziclay.com/api/v1/vstu_schedule_parser/result.json",
|
|
||||||
"stat": {
|
|
||||||
"total_parsing_time": -1,
|
|
||||||
"excels": {
|
|
||||||
"fine": 0,
|
|
||||||
"bad": 0
|
|
||||||
},
|
|
||||||
"groups": 0,
|
|
||||||
"unique_raws": -1
|
|
||||||
},
|
|
||||||
"api_notices": {
|
|
||||||
"updated_at": 1773523692,
|
|
||||||
"text_pre1": "Пожалуйста сохраняйте 'updated_at', это время изменения ЭТОГО текста. Тут возможно будут появлятся важные BREAKING CHANGES и дедлайны к ним.\nПо хорошему если updated_at другой по сравнению с вашем кэшем это сообщение должно отправляться вам в телеграм как уведомление о поедстоящих изменениях\nwarning=True значит 'text' содержит важное а не как щас hint.\n\n ~fazziclay aka Stanislav;\n\n2025-10-05: добавлено data_source_hash в эксель и в группу. Это SHA1 of скачанный эксель файл.",
|
|
||||||
"text": "2026-03-15 BREAKING CHANGES! By Stanislav Mironov.\n\nИзменено многое в угоду унифкации и расширению спаршенных групп. Пока alpha",
|
|
||||||
"warning": True,
|
|
||||||
"tut-plavayuschaya-struktura": "required only 'updated_at', 'text' and 'warning'"
|
|
||||||
},
|
|
||||||
"debug": {
|
|
||||||
"bleu~~": 3
|
|
||||||
},
|
|
||||||
"excels": [],
|
|
||||||
"facultets": FACULTETS,
|
|
||||||
"group_names_parsed": [],
|
|
||||||
"unique_raws": unique_raws,
|
|
||||||
"see_header_at_top_of_this_file": "SEE TOP OF THIS FILE | ОБРАТИТЕ ВНИМАНИЕ НА ВЕРХ ЭТОГО ФАЙЛА"
|
|
||||||
}
|
|
||||||
|
|
||||||
def process_obj(data):
|
def parse_sheets(download_place):
|
||||||
|
to_return = {}
|
||||||
try:
|
try:
|
||||||
if isinstance(data, dict):
|
|
||||||
for key, value in data.items():
|
|
||||||
if key == "raw":
|
|
||||||
unique_raws.update(value)
|
|
||||||
|
|
||||||
process_obj(value)
|
|
||||||
|
|
||||||
# Если это список, проходим по его элементам
|
|
||||||
elif isinstance(data, list):
|
|
||||||
for item in data:
|
|
||||||
process_obj(item)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print("Failed process_obj")
|
|
||||||
print(e)
|
|
||||||
|
|
||||||
def process_excel_file(facultet, excel_url, counter, latest_changed):
|
|
||||||
is_xlsx = excel_url.endswith(".xlsx")
|
|
||||||
download_place = f"{DIRNAME}/" + f"_[C{counter}]_" + facultet + ".xls" + ("x" if is_xlsx else "")
|
|
||||||
|
|
||||||
excel_filename = excel_url.split("/")[-1]
|
|
||||||
if "ФЭУ" not in excel_filename:
|
|
||||||
print("SKIPPED")
|
|
||||||
return
|
|
||||||
|
|
||||||
excel_info = {
|
|
||||||
"filename": excel_filename,
|
|
||||||
"data_source_hash": None,
|
|
||||||
"url": excel_url,
|
|
||||||
"latest_changed": latest_changed,
|
|
||||||
"download_place": download_place,
|
|
||||||
"group_names_parsed": [],
|
|
||||||
"facultet": facultet,
|
|
||||||
"counter": counter,
|
|
||||||
"sheets": []
|
|
||||||
}
|
|
||||||
parser.LOGGING = LOGGING
|
|
||||||
|
|
||||||
try:
|
|
||||||
aigenerated.download_file_from_url(excel_url, download_place)
|
|
||||||
sha1hash = hashes.calculate_sha1(download_place)
|
|
||||||
excel_info['data_source_hash'] = sha1hash
|
|
||||||
reader = translations.create_reader(download_place)
|
reader = translations.create_reader(download_place)
|
||||||
print("Reader info")
|
print("Reader info")
|
||||||
print(reader.info())
|
print(reader.info())
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
|
t = utils.StepTimeCounter()
|
||||||
print(f"Parsing sheet №{reader.get_sheet_index()+1} (from 1)")
|
print(f"Parsing sheet №{reader.get_sheet_index()+1} (from 1)")
|
||||||
sheet_dict = {
|
sheet_dict = {
|
||||||
"index": reader.get_sheet_index(),
|
"index": reader.get_sheet_index(),
|
||||||
"name": reader.get_sheet_name(),
|
"name": reader.get_sheet_name(),
|
||||||
"reader_info": reader.info(),
|
"reader_info": reader.info(),
|
||||||
"group_names_parsed": [],
|
|
||||||
"groups": {}
|
"groups": {}
|
||||||
}
|
}
|
||||||
excel_info['sheets'].append(sheet_dict)
|
to_return["SHEET_"+str(reader.get_sheet_index())] = sheet_dict
|
||||||
prs = parser.Parser(reader)
|
prs = parser.Parser(reader)
|
||||||
|
|
||||||
print("Parser created; parser.parse();")
|
print("Parser created; parser.parse();")
|
||||||
prs.parse()
|
prs.parse()
|
||||||
|
|
||||||
print("parsed done!")
|
print("parsed done!")
|
||||||
|
sheet_dict['parse_time'] = round(t.step())
|
||||||
|
|
||||||
if len(prs.raw_no_schedule) > 0:
|
if len(prs.raw_no_schedule) > 0:
|
||||||
sheet_dict["raw_no_schedule"] = prs.raw_no_schedule
|
sheet_dict["other_raws"] = prs.raw_no_schedule
|
||||||
|
|
||||||
if len(prs.features) > 0:
|
if len(prs.features) > 0:
|
||||||
sheet_dict["features"] = sorted(prs.features)
|
sheet_dict["features"] = sorted(prs.features)
|
||||||
@@ -147,16 +66,11 @@ def process_excel_file(facultet, excel_url, counter, latest_changed):
|
|||||||
if prs.parser_warnings is not None and len(prs.parser_warnings) > 0:
|
if prs.parser_warnings is not None and len(prs.parser_warnings) > 0:
|
||||||
sheet_dict["parser_warnings"] = prs.parser_warnings
|
sheet_dict["parser_warnings"] = prs.parser_warnings
|
||||||
|
|
||||||
for group_name in prs.groups.keys():
|
for group_name_key in prs.groups.keys():
|
||||||
gr = prs.groups[group_name]
|
gr = prs.groups[group_name_key]
|
||||||
gr["excel_url"] = excel_url
|
|
||||||
sheet_dict["group_names_parsed"].append(group_name)
|
|
||||||
excel_info["group_names_parsed"].append(group_name)
|
|
||||||
result["group_names_parsed"].append(group_name)
|
|
||||||
result['stat']['groups'] += 1
|
|
||||||
sheet_dict['week_keys_metadata'] = prs.week_keys_metadata
|
sheet_dict['week_keys_metadata'] = prs.week_keys_metadata
|
||||||
sheet_dict['groups'][group_name] = gr
|
sheet_dict['groups'][group_name_key] = gr
|
||||||
process_obj(gr['slots'])
|
|
||||||
|
|
||||||
print(f"Populates {len(prs.groups)} groups: " + " ".join(prs.groups.keys()))
|
print(f"Populates {len(prs.groups)} groups: " + " ".join(prs.groups.keys()))
|
||||||
|
|
||||||
@@ -168,31 +82,57 @@ def process_excel_file(facultet, excel_url, counter, latest_changed):
|
|||||||
print("Next sheet!")
|
print("Next sheet!")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error while {excel_url}")
|
|
||||||
print(e)
|
print(e)
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
u = uuid.uuid4()
|
u = uuid.uuid4()
|
||||||
excel_info['error'] = {
|
to_return['error'] = {
|
||||||
"smile": ":(",
|
"smile": ":(",
|
||||||
"error_message": str(e),
|
"error_message": str(e),
|
||||||
"log_anchor": str(u),
|
"log_anchor": str(u),
|
||||||
"time": currt()
|
"time": currt()
|
||||||
}
|
}
|
||||||
print(f"Log Anchor: {u}")
|
print(f"Log Anchor: {u}")
|
||||||
faileds.append({
|
|
||||||
"ex": e,
|
|
||||||
"fac": facultet,
|
|
||||||
"url": excel_url
|
|
||||||
})
|
|
||||||
|
|
||||||
result['excels'].append(excel_info)
|
return to_return
|
||||||
k = "fine" if len(excel_info['group_names_parsed']) > 0 else "bad"
|
|
||||||
result['stat']['excels'][k] += 1
|
def parsed_file_path(excel_filename: str):
|
||||||
|
format = excel_filename.split(".")[-1]
|
||||||
|
fl = format.lower()
|
||||||
|
|
||||||
|
if fl not in ["json", "xls", "xlsx"]:
|
||||||
|
print(f"Unknown filename format: {excel_filename}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if fl != "json":
|
||||||
|
excel_filename = excel_filename.replace("." + format, ".json")
|
||||||
|
|
||||||
|
excel_filename = excel_filename.lower()
|
||||||
|
filepath = PARSED_DIR + os.path.sep + excel_filename
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
def load_parsed_state(excel_filename):
|
||||||
|
filepath = parsed_file_path(excel_filename)
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(filepath, "r", encoding="utf-8") as fp:
|
||||||
|
return json.load(fp=fp)
|
||||||
|
|
||||||
|
def save_parsed_state(excel_filename, obj):
|
||||||
|
filepath = parsed_file_path(excel_filename)
|
||||||
|
|
||||||
|
with open(filepath, "w", encoding="utf-8") as fp:
|
||||||
|
json.dump(obj, fp=fp, ensure_ascii=False, sort_keys=True)
|
||||||
|
|
||||||
|
print(f"Saved parsed state to '{filepath}'")
|
||||||
|
|
||||||
|
|
||||||
|
def run_session():
|
||||||
|
faileds = []
|
||||||
|
|
||||||
faileds = []
|
|
||||||
def main():
|
|
||||||
global result
|
|
||||||
t = utils.StepTimeCounter()
|
t = utils.StepTimeCounter()
|
||||||
|
|
||||||
|
# Delete tempdir
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
shutil.rmtree(DIRNAME)
|
shutil.rmtree(DIRNAME)
|
||||||
@@ -205,43 +145,83 @@ def main():
|
|||||||
print(f"Failed create '{DIRNAME}': ")
|
print(f"Failed create '{DIRNAME}': ")
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
|
||||||
print("main(); parse links starting...")
|
print("main(); parse links starting...")
|
||||||
EXCEL_LINKS = links_parser.parse_links(FACULTETS if DEBUG_ONE_FAC is None else [DEBUG_ONE_FAC])
|
EXCEL_LINKS = links_parser.parse_links(FACULTETS if DEBUG_ONE_FAC is None else [DEBUG_ONE_FAC])
|
||||||
now_diffable_dates = links_parser.excels_to_diffabledates(EXCEL_LINKS)
|
|
||||||
prev_diffable_dates = None
|
|
||||||
if os.path.exists("diffable_dates.txt"):
|
|
||||||
with open(DIFFABLE_DATES, 'r') as fp:
|
|
||||||
prev_diffable_dates = fp.read().strip()
|
|
||||||
|
|
||||||
with open(DIFFABLE_DATES, 'w') as fp:
|
last_changeds = set()
|
||||||
fp.write(now_diffable_dates)
|
for excel_dict in EXCEL_LINKS:
|
||||||
|
try:
|
||||||
|
last_changeds.add(excel_dict['last_changed'])
|
||||||
|
|
||||||
if now_diffable_dates == prev_diffable_dates:
|
excel_url = excel_dict['url']
|
||||||
print("No date changes in vstu.ru website. Stopping")
|
facultet = excel_dict['facultet']
|
||||||
if not SKIP_DIFFABLE_DATES:
|
excel_filename = excel_url.split("/")[-1]
|
||||||
return
|
excel_dict['json_represent'] = parsed_file_path(excel_filename).split(os.path.sep)[-1]
|
||||||
print("SKIP_DIFFABLE_DATES is True, force resuming")
|
|
||||||
|
|
||||||
counter = 10000
|
|
||||||
for excel_link in EXCEL_LINKS:
|
|
||||||
counter += 1
|
|
||||||
facultet = excel_link['facultet']
|
|
||||||
excel_url = excel_link['url']
|
|
||||||
latest_changed = excel_link['last_changed']
|
|
||||||
process_excel_file(facultet, excel_url, counter, latest_changed)
|
|
||||||
|
|
||||||
print("Saving result.json")
|
state = load_parsed_state(excel_filename)
|
||||||
result['stat']['total_parsing_time'] = t.step()
|
is_new = state is None
|
||||||
result['unique_raws'] = sorted(unique_raws)
|
if is_new:
|
||||||
|
state = {}
|
||||||
|
|
||||||
json.dump(result, open('result.json', 'w'), indent=2, ensure_ascii=False)
|
else:
|
||||||
print("Saved to result.json indent=2")
|
same_date = False
|
||||||
|
try:
|
||||||
|
same_date = state['excel']['last_changed'] == excel_dict['last_changed']
|
||||||
|
print(f"Excel[{excel_filename}]: inServer={excel_dict['last_changed']}, inState={state['excel']['last_changed']} same={same_date}")
|
||||||
|
|
||||||
json.dump(result, open('result-no-indent.json', 'w'), ensure_ascii=False)
|
except Exception as e:
|
||||||
print("Saved to result-no-indent.json")
|
print(f"Excel[{excel_filename}]: failed testify last_changed")
|
||||||
|
|
||||||
print("Faileds:")
|
if same_date:
|
||||||
print(faileds)
|
state['actual_at'] = currt()
|
||||||
|
try:
|
||||||
|
del state['excel']['different_in_this_session']
|
||||||
|
except: pass
|
||||||
|
save_parsed_state(excel_filename, state)
|
||||||
|
continue
|
||||||
|
|
||||||
|
excel_dict['different_in_this_session'] = True
|
||||||
|
state['actual_at'] = currt()
|
||||||
|
state['excel'] = excel_dict
|
||||||
|
|
||||||
|
is_xlsx = excel_url.endswith(".xlsx")
|
||||||
|
download_place = f"{DIRNAME}/" + excel_filename + "_" + facultet + ".xls" + ("x" if is_xlsx else "")
|
||||||
|
utils.download_file_from_url(excel_url, download_place)
|
||||||
|
sha1hash = utils.calculate_sha1(download_place)
|
||||||
|
state['excel']['sha1hash'] = sha1hash
|
||||||
|
|
||||||
|
state['sheets'] = parse_sheets(download_place)
|
||||||
|
|
||||||
|
save_parsed_state(excel_filename, state)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
faileds.append({
|
||||||
|
"uuid": str(uuid.uuid4()),
|
||||||
|
"exception": str(e),
|
||||||
|
"traceback": traceback.format_exception(e),
|
||||||
|
"context": f"Failed process excel file {excel_dict['url']}"
|
||||||
|
})
|
||||||
|
traceback.print_exception(e)
|
||||||
|
|
||||||
|
|
||||||
|
with open("parser.json", 'w', encoding="utf-8") as fp:
|
||||||
|
lc = {"*_x": ":("}
|
||||||
|
try:
|
||||||
|
s = sorted(last_changeds)
|
||||||
|
lc = {
|
||||||
|
"early": s[0],
|
||||||
|
"newly": s[-1]
|
||||||
|
}
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
json.dump({
|
||||||
|
"last_changeds": lc,
|
||||||
|
"actual_at": currt(),
|
||||||
|
"all_files": EXCEL_LINKS,
|
||||||
|
"faileds": faileds
|
||||||
|
}, fp=fp, ensure_ascii=False)
|
||||||
|
|
||||||
# Delete a non-empty directory and its contents
|
# Delete a non-empty directory and its contents
|
||||||
try:
|
try:
|
||||||
@@ -251,6 +231,22 @@ def main():
|
|||||||
print(f"Error deleting directory '{DIRNAME}': {e}")
|
print(f"Error deleting directory '{DIRNAME}': {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
print("BEGIN run_session();")
|
||||||
|
run_session()
|
||||||
|
print("END run_session();")
|
||||||
|
except Exception as e:
|
||||||
|
print("Exception in run_session();")
|
||||||
|
traceback.print_exception(e)
|
||||||
|
|
||||||
|
print("Sleep for 30 minutes")
|
||||||
|
time.sleep(60*30)
|
||||||
|
print("Wake up!")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print("Start")
|
print("Start")
|
||||||
main()
|
main()
|
||||||
|
|||||||
57
parser.py
57
parser.py
@@ -9,10 +9,11 @@ WEEKDAYS_STARTSWITH = [
|
|||||||
"среда",
|
"среда",
|
||||||
"четверг",
|
"четверг",
|
||||||
"пятница",
|
"пятница",
|
||||||
"суббота"
|
"суббота",
|
||||||
|
"воскр"
|
||||||
]
|
]
|
||||||
|
|
||||||
bad_group_names = [
|
BAD_GROUP_NAMES = [
|
||||||
"янв", "февр", "март", "апр", "май", "сент", "окт", "ноя", "дек", "июнь", "июль", "авг"
|
"янв", "февр", "март", "апр", "май", "сент", "окт", "ноя", "дек", "июнь", "июль", "авг"
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -68,6 +69,7 @@ class Parser:
|
|||||||
print(" -- Failed parse! -- ")
|
print(" -- Failed parse! -- ")
|
||||||
print("дни недели не найдены!")
|
print("дни недели не найдены!")
|
||||||
self.parser_error = f"{WEEKDAYS_STARTSWITH} ни один найден в таблице. Дня недели нет."
|
self.parser_error = f"{WEEKDAYS_STARTSWITH} ни один найден в таблице. Дня недели нет."
|
||||||
|
self.parse_raw_no_schedule()
|
||||||
return
|
return
|
||||||
|
|
||||||
pair_num_any = self.reader.find_any(PAIR_NUMS, nospace=True)
|
pair_num_any = self.reader.find_any(PAIR_NUMS, nospace=True)
|
||||||
@@ -98,6 +100,7 @@ class Parser:
|
|||||||
print(head_joined)
|
print(head_joined)
|
||||||
if "1 неделя" in head_joined or "1 НЕДЕЛЯ" in head_joined or "2 неделя" in head_joined or "2 НЕДЕЛЯ" in head_joined or "ИЗМЕНЕНИЯ" in head_joined or "изменения" in head_joined or "vtf-vstu.ru" in head_joined:
|
if "1 неделя" in head_joined or "1 НЕДЕЛЯ" in head_joined or "2 неделя" in head_joined or "2 НЕДЕЛЯ" in head_joined or "ИЗМЕНЕНИЯ" in head_joined or "изменения" in head_joined or "vtf-vstu.ru" in head_joined:
|
||||||
head_rx -= 1
|
head_rx -= 1
|
||||||
|
self.raw_no_schedule.append(head_joined)
|
||||||
head = self.reader.get_row_values(head_rx) # get all ROW (months, groups)
|
head = self.reader.get_row_values(head_rx) # get all ROW (months, groups)
|
||||||
pprint(f"head (upper)={head}")
|
pprint(f"head (upper)={head}")
|
||||||
self.features.add("post_groups_info_row")
|
self.features.add("post_groups_info_row")
|
||||||
@@ -133,7 +136,7 @@ class Parser:
|
|||||||
def parse_raw_no_schedule(self):
|
def parse_raw_no_schedule(self):
|
||||||
"""Распарсить всё за пределами self.schedule_range_row в self.raw_no_schedule"""
|
"""Распарсить всё за пределами self.schedule_range_row в self.raw_no_schedule"""
|
||||||
if self.schedule_range_row is None:
|
if self.schedule_range_row is None:
|
||||||
return
|
self.schedule_range_row = [999999999, 999999999] # прекрасное далёко
|
||||||
|
|
||||||
row = 0
|
row = 0
|
||||||
while row < self.reader.get_row_count():
|
while row < self.reader.get_row_count():
|
||||||
@@ -209,6 +212,7 @@ class Parser:
|
|||||||
self.weeknums[week_key_name].add(merged)
|
self.weeknums[week_key_name].add(merged)
|
||||||
|
|
||||||
def row_with_schedule_notify(self, row_coord):
|
def row_with_schedule_notify(self, row_coord):
|
||||||
|
"""Вызывается каждый раз когда в переданной row обранужено расписание"""
|
||||||
if self.schedule_range_row is None:
|
if self.schedule_range_row is None:
|
||||||
self.schedule_range_row = [row_coord, row_coord]
|
self.schedule_range_row = [row_coord, row_coord]
|
||||||
|
|
||||||
@@ -218,20 +222,6 @@ class Parser:
|
|||||||
if self.schedule_range_row[0] > row_coord:
|
if self.schedule_range_row[0] > row_coord:
|
||||||
self.schedule_range_row[0] = row_coord
|
self.schedule_range_row[0] = row_coord
|
||||||
|
|
||||||
def parse_potokoviy(self, merged: Merged):
|
|
||||||
speaker = None
|
|
||||||
location = None
|
|
||||||
|
|
||||||
# speaker
|
|
||||||
low = merged.low
|
|
||||||
speaker_pos = low.shift(down=merged.height())
|
|
||||||
speaker = speaker_pos.cell(self.reader).value
|
|
||||||
|
|
||||||
# location
|
|
||||||
location = merged.high.shift(down=1).cell(self.reader).value
|
|
||||||
|
|
||||||
return {"loc": str(location).strip(), "leader": str(speaker).strip(), "name": str(merged.cell(self.reader).value).strip()}
|
|
||||||
|
|
||||||
def process_group(self, group: dict, first_weekday: Coord, pair_pos_col):
|
def process_group(self, group: dict, first_weekday: Coord, pair_pos_col):
|
||||||
"""
|
"""
|
||||||
Обработать группы, выполняется для каждой группы, после того как они распарены (parse_groups)
|
Обработать группы, выполняется для каждой группы, после того как они распарены (parse_groups)
|
||||||
@@ -245,14 +235,12 @@ class Parser:
|
|||||||
group_header_pos = Coord(group['position'][0], group['position'][1])
|
group_header_pos = Coord(group['position'][0], group['position'][1])
|
||||||
width = group['width']
|
width = group['width']
|
||||||
weeknum = 1 # номер недели, щёлкнет +1 при каком-то условии.
|
weeknum = 1 # номер недели, щёлкнет +1 при каком-то условии.
|
||||||
previous_pair = None
|
|
||||||
|
|
||||||
weekcycles = 0
|
weekcycles = 0
|
||||||
while row_c1 < self.reader.get_row_count():
|
while row_c1 < self.reader.get_row_count():
|
||||||
pos_c1 = Coord(row_c1, group['position'][1]) # текущая позиция, верхний левый угол (=low)
|
pos_c1 = Coord(row_c1, group['position'][1]) # текущая позиция, верхний левый угол (=low)
|
||||||
self.row_with_schedule_notify(pos_c1.row)
|
self.row_with_schedule_notify(pos_c1.row)
|
||||||
|
|
||||||
|
|
||||||
if pos_c1.cell(self.reader).is_nospace_nocase_same(group_name):
|
if pos_c1.cell(self.reader).is_nospace_nocase_same(group_name):
|
||||||
pprint("Ended with grpup name; stop moving down, break")
|
pprint("Ended with grpup name; stop moving down, break")
|
||||||
break
|
break
|
||||||
@@ -316,6 +304,7 @@ class Parser:
|
|||||||
pair_num_to_add = pair_num_mr.cell(self.reader).value.replace(" ", "").strip()
|
pair_num_to_add = pair_num_mr.cell(self.reader).value.replace(" ", "").strip()
|
||||||
if len(pair_num_to_add) == 0:
|
if len(pair_num_to_add) == 0:
|
||||||
pair_num_to_add = "???"
|
pair_num_to_add = "???"
|
||||||
|
pprint("Составители эксельки? Вы почему не указали номер пары ёклмн")
|
||||||
pairs.add(pair_num_to_add)
|
pairs.add(pair_num_to_add)
|
||||||
|
|
||||||
for cell in dirty_line:
|
for cell in dirty_line:
|
||||||
@@ -351,7 +340,6 @@ class Parser:
|
|||||||
pair_name = sorted(pairs)[0]
|
pair_name = sorted(pairs)[0]
|
||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
today = slots[w]
|
|
||||||
obj = {
|
obj = {
|
||||||
"object": "event",
|
"object": "event",
|
||||||
"pairs": sorted(pairs),
|
"pairs": sorted(pairs),
|
||||||
@@ -364,10 +352,29 @@ class Parser:
|
|||||||
if len(times) > 0:
|
if len(times) > 0:
|
||||||
obj['times'] = times
|
obj['times'] = times
|
||||||
|
|
||||||
if pair_pos_col is None:
|
def smart_insert(first_dict, key, to_insert):
|
||||||
slots[w] = obj
|
if key not in first_dict.keys():
|
||||||
|
first_dict[key] = {}
|
||||||
|
|
||||||
|
if isinstance(first_dict[key], dict):
|
||||||
|
if len(first_dict[key].keys()) == 0:
|
||||||
|
first_dict[key] = to_insert
|
||||||
else:
|
else:
|
||||||
today[pair_name] = obj
|
p = first_dict[key]
|
||||||
|
first_dict[key] = [p, to_insert]
|
||||||
|
|
||||||
|
elif isinstance(first_dict[key], list):
|
||||||
|
first_dict[key].append(to_insert)
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.parser_warnings.append("Wtf? first_dict[key] not is dict and not is list??? (internal error)")
|
||||||
|
|
||||||
|
if pair_pos_col is None:
|
||||||
|
smart_insert(slots, w, obj)
|
||||||
|
|
||||||
|
else:
|
||||||
|
smart_insert(slots[w], pair_name, obj)
|
||||||
|
|
||||||
# here may be a empty all_raw
|
# here may be a empty all_raw
|
||||||
clean_state()
|
clean_state()
|
||||||
first_coord = None
|
first_coord = None
|
||||||
@@ -397,13 +404,13 @@ def parse_groups(reader: "ExcelSheetReader", head, col_start, head_rx):
|
|||||||
name = utils.unspace(x)
|
name = utils.unspace(x)
|
||||||
skip = False
|
skip = False
|
||||||
if "-" not in name:
|
if "-" not in name:
|
||||||
for x in bad_group_names:
|
for x in BAD_GROUP_NAMES:
|
||||||
if x in name.lower():
|
if x in name.lower():
|
||||||
skip = True
|
skip = True
|
||||||
pprint(f"Skip groupname {name} because not dash in name and in blacklist")
|
pprint(f"Skip groupname {name} because not dash in name and in blacklist")
|
||||||
|
|
||||||
if not skip:
|
if not skip:
|
||||||
groups[name] = {
|
groups[name.lower()] = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"position": [head_rx, i],
|
"position": [head_rx, i],
|
||||||
"width": merged.width(),
|
"width": merged.width(),
|
||||||
|
|||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
openpyxl
|
||||||
|
xlrd
|
||||||
|
beautifulsoup4
|
||||||
|
requests
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# --- Абстрактный базовый класс (Контракт) ---
|
# Copyright Stanislav Mironov
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from datetime import datetime, time
|
from datetime import time
|
||||||
|
|
||||||
import openpyxl
|
import openpyxl
|
||||||
import xlrd
|
import xlrd
|
||||||
@@ -27,6 +27,7 @@ class TranschendentnostCell:
|
|||||||
def is_empty(self):
|
def is_empty(self):
|
||||||
return self._is_empty
|
return self._is_empty
|
||||||
|
|
||||||
|
# --- Абстрактный базовый класс (Контракт) ---
|
||||||
class ExcelSheetReader(ABC):
|
class ExcelSheetReader(ABC):
|
||||||
"""
|
"""
|
||||||
Абстрактный базовый класс, определяющий интерфейс для чтения данных из листа Excel.
|
Абстрактный базовый класс, определяющий интерфейс для чтения данных из листа Excel.
|
||||||
@@ -130,7 +131,6 @@ class ExcelSheetReader(ABC):
|
|||||||
|
|
||||||
|
|
||||||
# --- Реализация №1: Обертка для xlrd ---
|
# --- Реализация №1: Обертка для xlrd ---
|
||||||
|
|
||||||
class XlrdSheetReader(ExcelSheetReader):
|
class XlrdSheetReader(ExcelSheetReader):
|
||||||
def __init__(self, file_path, sheet_index=0):
|
def __init__(self, file_path, sheet_index=0):
|
||||||
super().__init__(file_path)
|
super().__init__(file_path)
|
||||||
@@ -219,7 +219,6 @@ class XlrdSheetReader(ExcelSheetReader):
|
|||||||
|
|
||||||
|
|
||||||
# --- Реализация №2: Обертка-транслятор для openpyxl ---
|
# --- Реализация №2: Обертка-транслятор для openpyxl ---
|
||||||
|
|
||||||
class OpenpyxlSheetReader(ExcelSheetReader):
|
class OpenpyxlSheetReader(ExcelSheetReader):
|
||||||
def __init__(self, file_path, sheet_name=None):
|
def __init__(self, file_path, sheet_name=None):
|
||||||
super().__init__(file_path)
|
super().__init__(file_path)
|
||||||
@@ -309,8 +308,7 @@ class OpenpyxlSheetReader(ExcelSheetReader):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
# --- Фабричная функция (Ваша единственная точка входа) ---
|
# --- Фабричная функция (единственная точка входа) ---
|
||||||
|
|
||||||
def create_reader(file_path, **kwargs) -> ExcelSheetReader:
|
def create_reader(file_path, **kwargs) -> ExcelSheetReader:
|
||||||
"""
|
"""
|
||||||
Создает и возвращает подходящий экземпляр ридера в зависимости от расширения файла.
|
Создает и возвращает подходящий экземпляр ридера в зависимости от расширения файла.
|
||||||
|
|||||||
129
utils.py
129
utils.py
@@ -1,12 +1,82 @@
|
|||||||
|
|
||||||
# Copyright Stanislav Mironov
|
# Copyright Stanislav Mironov
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import xlrd
|
import xlrd
|
||||||
from coord import Coord, Merged
|
from coord import Coord
|
||||||
from translations import ExcelSheetReader
|
from translations import ExcelSheetReader
|
||||||
import re
|
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from urllib.parse import urlsplit, urlunsplit, quote
|
||||||
|
|
||||||
|
def download_file_from_url(url, output_filename):
|
||||||
|
"""
|
||||||
|
Скачивает файл по URL со спецсимволами и пробелами, сохраняя его под указанным именем.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url (str): Исходный URL, который может содержать пробелы и кириллицу.
|
||||||
|
output_filename (str): Имя файла для сохранения (например, 'calc.xls').
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# --- Шаг 1: Правильное кодирование URL ---
|
||||||
|
# Разбираем URL на части: ('https', 'www.vstu.ru', '/path/to file.xls', '', '')
|
||||||
|
parts = urlsplit(url)
|
||||||
|
|
||||||
|
# Кодируем только путь, оставляя слэши '/' безопасными
|
||||||
|
# Это превратит ' ' в '%20', 'В' в '%D0%92' и т.д.
|
||||||
|
encoded_path = quote(parts.path, safe='/-_')
|
||||||
|
|
||||||
|
# Собираем URL обратно из частей с уже закодированным путем
|
||||||
|
encoded_url = urlunsplit((parts.scheme, parts.netloc, encoded_path, parts.query, parts.fragment))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Шаг 2: Скачивание файла ---
|
||||||
|
response = requests.get(encoded_url, stream=True)
|
||||||
|
|
||||||
|
# Проверяем, успешен ли запрос (код 200 OK)
|
||||||
|
# Если сервер вернет ошибку (404, 500 и т.д.), здесь возникнет исключение
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# --- Шаг 3: Сохранение файла ---
|
||||||
|
# Открываем файл для записи в бинарном режиме ('wb')
|
||||||
|
# Использование 'with' гарантирует, что файл будет закрыт автоматически
|
||||||
|
with open(output_filename, 'wb') as f:
|
||||||
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
print(f"✅ Файл успешно скачан и сохранен как '{output_filename}'")
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"❌ Ошибка скачивания: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Произошла непредвиденная ошибка: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_sha1(filepath):
|
||||||
|
"""
|
||||||
|
Calculates the SHA1 hash of a given file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath (str): The path to the file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The hexadecimal representation of the SHA1 hash, or None if the file is not found.
|
||||||
|
"""
|
||||||
|
sha1_hash = hashlib.sha1()
|
||||||
|
try:
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
# Read the file in chunks to handle large files efficiently
|
||||||
|
for chunk in iter(lambda: f.read(4096), b""):
|
||||||
|
sha1_hash.update(chunk)
|
||||||
|
return sha1_hash.hexdigest()
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"Error: File not found at {filepath}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"An error occurred: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
class StepTimeCounter:
|
class StepTimeCounter:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -69,58 +139,6 @@ def parse_all_dirt(reader: "ExcelSheetReader", min_pos: Coord, right, down, with
|
|||||||
|
|
||||||
return RET
|
return RET
|
||||||
|
|
||||||
# GEMINI GENERATED
|
|
||||||
def normalize_name(raw_name):
|
|
||||||
"""
|
|
||||||
Приводит разнородные записи ФИО к единому структурированному виду.
|
|
||||||
"""
|
|
||||||
# Шаг 1: Очистка
|
|
||||||
name = re.sub(r'\s+', ' ', raw_name).strip()
|
|
||||||
|
|
||||||
# Шаг 2: Извлечение звания
|
|
||||||
known_titles = ['проф.', 'доц.', 'акад.', 'к.т.н.', 'д.м.н.']
|
|
||||||
title = None
|
|
||||||
for t in known_titles:
|
|
||||||
if name.lower().startswith(t):
|
|
||||||
title = t
|
|
||||||
# Удаляем звание из строки, убираем лишние пробелы
|
|
||||||
name = name[len(t):].strip()
|
|
||||||
break
|
|
||||||
|
|
||||||
# Шаг 3 и 4: Разделение и идентификация
|
|
||||||
parts = name.split(' ')
|
|
||||||
last_name = None
|
|
||||||
initials = None
|
|
||||||
|
|
||||||
# Простой эвристический анализ
|
|
||||||
# Ищем инициалы (содержат точку или состоят из 1-2 заглавных букв)
|
|
||||||
initials_parts = []
|
|
||||||
name_parts = []
|
|
||||||
|
|
||||||
for part in parts:
|
|
||||||
if '.' in part or (1 <= len(part) <= 2 and part.isupper()):
|
|
||||||
initials_parts.append(part)
|
|
||||||
else:
|
|
||||||
# Считаем все остальное частью фамилии (для двойных фамилий)
|
|
||||||
name_parts.append(part)
|
|
||||||
|
|
||||||
if name_parts:
|
|
||||||
last_name = " ".join(name_parts)
|
|
||||||
|
|
||||||
if initials_parts:
|
|
||||||
initials = "".join(initials_parts) # Сливаем "А." и "Н." в "А.Н."
|
|
||||||
|
|
||||||
# Если фамилия не найдена (например, только инициалы),
|
|
||||||
# но есть части, считаем первую часть фамилией
|
|
||||||
if not last_name and name_parts:
|
|
||||||
last_name = name_parts[0]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"last_name": last_name,
|
|
||||||
"initials": initials,
|
|
||||||
"title": title
|
|
||||||
}
|
|
||||||
|
|
||||||
def excel_coordinate(row, col):
|
def excel_coordinate(row, col):
|
||||||
"""
|
"""
|
||||||
Преобразует координаты строки и столбца (начиная с 0) в эквивалент Excel (например, A7, CB34).
|
Преобразует координаты строки и столбца (начиная с 0) в эквивалент Excel (например, A7, CB34).
|
||||||
@@ -179,5 +197,6 @@ def weekday_to_num(st: str):
|
|||||||
if st.upper().strip().startswith("ВОСКР"):
|
if st.upper().strip().startswith("ВОСКР"):
|
||||||
return 7
|
return 7
|
||||||
|
|
||||||
|
print(f"Unknown weekday num for str: {st}; returnted -1")
|
||||||
return -1
|
return -1
|
||||||
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
{
|
|
||||||
"actual_at": "0", // unix seconds когда было распарсено это расписание
|
|
||||||
"notice": "LEGAL INFORMATION HERE", // legal information
|
|
||||||
"stat": {
|
|
||||||
"total_parsing_time": 100.11 // сколько занял процесс парсинга от запуска программы (сек)
|
|
||||||
},
|
|
||||||
"version": "1", // версия = 1
|
|
||||||
"excels": [ // список всех эксель файлов которые были обработаны.
|
|
||||||
{
|
|
||||||
"filename": "эксель как на сайте.xls",
|
|
||||||
"url": "https://internet./path/to/эксель как на сайте.xls", // откуда парсер скачал
|
|
||||||
"download_place": "excels/temp.xls", // куда парсер скачал
|
|
||||||
"group_names_parsed": [""], // список названий групп которые удалось достать с этого файла
|
|
||||||
"facultet": "mag", // english name of facultet к которому относится этот эксель документ
|
|
||||||
"stat": {
|
|
||||||
"download": 0.314, // сколько скачивалось (сек)
|
|
||||||
"create_reader": 0.03, // сколько создавался Reader (сек), обычно у .xlsx это от нескольких секунд до минуты, а у .xls десятые доли секунды.
|
|
||||||
"parse": 0.00, // сколько парсился (сек)
|
|
||||||
"cycles": 2 // сколько парсер сделал циклов: по факту - сколько страниц в эксель файле
|
|
||||||
},
|
|
||||||
|
|
||||||
// дальше optional
|
|
||||||
"parser_error_cycle_1": "string desk from Parser", // _1 номер цикла. не больше stat.cycles
|
|
||||||
"error": {"...":"..."} // исключение в коде
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
"groups": { // список групп с данными о них
|
|
||||||
"имя-группы": {
|
|
||||||
"name": "имя-группы",
|
|
||||||
"position_human": "G6:J6", // range ячейки-заголовка группы в эксель таблице.
|
|
||||||
"slots": { // слоты - расписание этой группы на 2 недели
|
|
||||||
|
|
||||||
// важно если у группы нету например занятий в первый понидельник то и ключа не будет. => если у группы нету занятий вообще то slots = {} (пустому словарю)
|
|
||||||
|
|
||||||
// день определённой недели
|
|
||||||
// ключ - имя дня недели + (_1 OR _2 в зависимости от недели). Не полагайтесь на этот ключ, т.к. в каждой паре есть weekday (1-7) и weeknum (1-2).
|
|
||||||
// значение - список пар на этот день
|
|
||||||
"ПОНЕДЕЛЬНИК_1": {
|
|
||||||
"1-2": { // ключ - название времени пары (1-2, 2-3, 3-4 etc...), значение - информация о паре (о дисциплине в это время)
|
|
||||||
"excel_pos": "G7", // позиция в экселе
|
|
||||||
"discipline_name": "Великое знание российское", // название дисциплины (не всегда корректное)
|
|
||||||
"locations": [ // список локаций (не всегда корректный)
|
|
||||||
"В 1204"
|
|
||||||
],
|
|
||||||
"leads": [ // список лидеров (спикеров, вообщем преподователей) (не всегда корректный)
|
|
||||||
"Бадикова П.В."
|
|
||||||
],
|
|
||||||
"is_solid": true, // единая ли верхняя ячейка (т.е. ширина 4 колонки и она объединена в одну)
|
|
||||||
"time_coeff": 2, // по сути сколько пар длится пара. Тоесть если >1 то длится больше одной пары. Почти всегда 1, иногда 2. В редких случаях (химия) 3 :dead: соболезную трудящимся
|
|
||||||
"is_flow": false, // потоковая? определяется если ширина объединённой ячейки больше 4 (а 4 это размер колонки группы).
|
|
||||||
"lefttopmerged": { // частично отладочная информация, как раз о объединённой ячейке взятой как тык пальцем в верхнюю левую ячейку.
|
|
||||||
"width": 4,
|
|
||||||
"height": 2,
|
|
||||||
"excel_range": "G7:J8" // её range в экселе
|
|
||||||
},
|
|
||||||
"raw": [ // сырые данные. Содержание всех не пустых ячеек этой записи о паре. (SET data structure)
|
|
||||||
"Великое знание российское",
|
|
||||||
"В 1204б,лунатутможетбытьчтоугодно",
|
|
||||||
"даты и т.д. всё что там есть будет тут",
|
|
||||||
"практ. 4 час.",
|
|
||||||
"Преподов П.П."
|
|
||||||
],
|
|
||||||
"weekday": 1, // день недели 1- понидельник, 7 - воскр..
|
|
||||||
"weeknum": 1, // номер недели 1- первая 2-вторая.
|
|
||||||
|
|
||||||
// дальше optional
|
|
||||||
"pair_num_empty": {"..": "...", "restored": true}, // составители расписания лучшие и в макете забыли сбоку добавить [1-2] номер пары. Если restored=true то это недоразумение удалось исправить опираясь на предыдущую пару.
|
|
||||||
"to_many_parsing_time_coeff": true // если при попытке определить time_coeff, а он определяется по границам (borders) в эксель таблице, произошла ошибка (подавленный infinity loop)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"facultet": "mag", // факультет группы
|
|
||||||
"data_source": "эксельтаблица.xls" // название таблицы откуда были получены данные о этой группе
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// безусловно перечислено не всё, но многое излишне.
|
|
||||||
// ~ Stanislav Mironov
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user