From 1199ce15543c057cdf49975ab272869d29626e09 Mon Sep 17 00:00:00 2001 From: FazziCLAY Date: Wed, 18 Mar 2026 22:15:49 +0300 Subject: [PATCH] refactor: big refactor --- .gitignore | 4 +- Dockerfile | 6 + aigenerated.py | 201 ----------------------- hashes.py | 34 ---- links_parser.py | 11 +- main.py | 282 ++++++++++++++++----------------- parser.py | 57 ++++--- requirements.txt | 4 + translations.py | 10 +- utils.py | 129 ++++++++------- vstu_parser_result_scheme.json | 81 ---------- 11 files changed, 264 insertions(+), 555 deletions(-) delete mode 100644 aigenerated.py delete mode 100644 hashes.py create mode 100644 requirements.txt delete mode 100644 vstu_parser_result_scheme.json diff --git a/.gitignore b/.gitignore index 2dec1e4..4648488 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ __pycache__ .idea result*.json groups.json -diffable_dates.txt \ No newline at end of file +diffable_dates.txt +parsed +parser.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e69de29..604a7d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/aigenerated.py b/aigenerated.py deleted file mode 100644 index 468509c..0000000 --- a/aigenerated.py +++ /dev/null @@ -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}") - - - diff --git a/hashes.py b/hashes.py deleted file mode 100644 index fe847fe..0000000 --- a/hashes.py +++ /dev/null @@ -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}") \ No newline at end of file diff --git a/links_parser.py b/links_parser.py index 584a02f..d2c032c 100644 --- a/links_parser.py +++ b/links_parser.py @@ -51,15 +51,8 @@ def parse_links(facultets): "url": url, "last_changed": last_changed } - print(record) + print("Found in vstu.ru: ", record) EXCEL_LINKS.append(record) - + 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() diff --git a/main.py b/main.py index fbd80ed..e9f5e36 100644 --- a/main.py +++ b/main.py @@ -9,14 +9,12 @@ import random import time import traceback import uuid -import aigenerated import parser import translations import utils import json import links_parser import shutil -import hashes def currt(): return round(time.time()) @@ -25,119 +23,40 @@ FACULTETS = sorted([ "asp", "mag", "fastiv", "fat", "ftkm", "ftpp", "feu", "fevt", "htf", "vkf", "mmf", "fpik" ]) DIRNAME = "excels" -DIFFABLE_DATES = "diffable_dates.txt" - -SKIP_DIFFABLE_DATES = True +PARSED_DIR = "parsed" DEBUG_ONE_FAC = None #'fevt' -LOGGING = False -unique_raws = set() -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 | ОБРАТИТЕ ВНИМАНИЕ НА ВЕРХ ЭТОГО ФАЙЛА" -} +parser.LOGGING = LOGGING = False -def process_obj(data): +def parse_sheets(download_place): + to_return = {} 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) print("Reader info") print(reader.info()) while True: + t = utils.StepTimeCounter() print(f"Parsing sheet №{reader.get_sheet_index()+1} (from 1)") sheet_dict = { "index": reader.get_sheet_index(), "name": reader.get_sheet_name(), "reader_info": reader.info(), - "group_names_parsed": [], "groups": {} } - excel_info['sheets'].append(sheet_dict) + to_return["SHEET_"+str(reader.get_sheet_index())] = sheet_dict prs = parser.Parser(reader) print("Parser created; parser.parse();") prs.parse() print("parsed done!") + sheet_dict['parse_time'] = round(t.step()) 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: 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: sheet_dict["parser_warnings"] = prs.parser_warnings - for group_name in prs.groups.keys(): - gr = prs.groups[group_name] - 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 + for group_name_key in prs.groups.keys(): + gr = prs.groups[group_name_key] sheet_dict['week_keys_metadata'] = prs.week_keys_metadata - sheet_dict['groups'][group_name] = gr - process_obj(gr['slots']) + sheet_dict['groups'][group_name_key] = gr + 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!") except Exception as e: - print(f"Error while {excel_url}") print(e) traceback.print_exc() u = uuid.uuid4() - excel_info['error'] = { + to_return['error'] = { "smile": ":(", "error_message": str(e), "log_anchor": str(u), "time": currt() } print(f"Log Anchor: {u}") - faileds.append({ - "ex": e, - "fac": facultet, - "url": excel_url - }) - - result['excels'].append(excel_info) - k = "fine" if len(excel_info['group_names_parsed']) > 0 else "bad" - result['stat']['excels'][k] += 1 + + return to_return -faileds = [] -def main(): - global result +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 = [] + t = utils.StepTimeCounter() + + # Delete tempdir try: try: shutil.rmtree(DIRNAME) @@ -205,45 +145,85 @@ def main(): print(f"Failed create '{DIRNAME}': ") raise e + print("main(); parse links starting...") 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: - fp.write(now_diffable_dates) + last_changeds = set() + for excel_dict in EXCEL_LINKS: + try: + last_changeds.add(excel_dict['last_changed']) - if now_diffable_dates == prev_diffable_dates: - print("No date changes in vstu.ru website. Stopping") - if not SKIP_DIFFABLE_DATES: - return - 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) + excel_url = excel_dict['url'] + facultet = excel_dict['facultet'] + excel_filename = excel_url.split("/")[-1] + excel_dict['json_represent'] = parsed_file_path(excel_filename).split(os.path.sep)[-1] - print("Saving result.json") - result['stat']['total_parsing_time'] = t.step() - result['unique_raws'] = sorted(unique_raws) - json.dump(result, open('result.json', 'w'), indent=2, ensure_ascii=False) - print("Saved to result.json indent=2") + state = load_parsed_state(excel_filename) + is_new = state is None + if is_new: + state = {} + + else: + 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}") + + except Exception as e: + print(f"Excel[{excel_filename}]: failed testify last_changed") + + if same_date: + 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) - json.dump(result, open('result-no-indent.json', 'w'), ensure_ascii=False) - print("Saved to result-no-indent.json") - print("Faileds:") - print(faileds) + 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: shutil.rmtree(DIRNAME) print(f"Directory '{DIRNAME}' and its contents deleted successfully.") @@ -251,6 +231,22 @@ def main(): 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__": print("Start") main() diff --git a/parser.py b/parser.py index 649fdbe..ae07fb1 100644 --- a/parser.py +++ b/parser.py @@ -9,10 +9,11 @@ WEEKDAYS_STARTSWITH = [ "среда", "четверг", "пятница", - "суббота" + "суббота", + "воскр" ] -bad_group_names = [ +BAD_GROUP_NAMES = [ "янв", "февр", "март", "апр", "май", "сент", "окт", "ноя", "дек", "июнь", "июль", "авг" ] @@ -68,6 +69,7 @@ class Parser: print(" -- Failed parse! -- ") print("дни недели не найдены!") self.parser_error = f"{WEEKDAYS_STARTSWITH} ни один найден в таблице. Дня недели нет." + self.parse_raw_no_schedule() return pair_num_any = self.reader.find_any(PAIR_NUMS, nospace=True) @@ -98,6 +100,7 @@ class Parser: 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: head_rx -= 1 + self.raw_no_schedule.append(head_joined) head = self.reader.get_row_values(head_rx) # get all ROW (months, groups) pprint(f"head (upper)={head}") self.features.add("post_groups_info_row") @@ -133,7 +136,7 @@ class Parser: def parse_raw_no_schedule(self): """Распарсить всё за пределами self.schedule_range_row в self.raw_no_schedule""" if self.schedule_range_row is None: - return + self.schedule_range_row = [999999999, 999999999] # прекрасное далёко row = 0 while row < self.reader.get_row_count(): @@ -209,6 +212,7 @@ class Parser: self.weeknums[week_key_name].add(merged) def row_with_schedule_notify(self, row_coord): + """Вызывается каждый раз когда в переданной row обранужено расписание""" if self.schedule_range_row is None: self.schedule_range_row = [row_coord, row_coord] @@ -218,20 +222,6 @@ class Parser: if 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): """ Обработать группы, выполняется для каждой группы, после того как они распарены (parse_groups) @@ -245,13 +235,11 @@ class Parser: group_header_pos = Coord(group['position'][0], group['position'][1]) width = group['width'] weeknum = 1 # номер недели, щёлкнет +1 при каком-то условии. - previous_pair = None weekcycles = 0 while row_c1 < self.reader.get_row_count(): pos_c1 = Coord(row_c1, group['position'][1]) # текущая позиция, верхний левый угол (=low) self.row_with_schedule_notify(pos_c1.row) - if pos_c1.cell(self.reader).is_nospace_nocase_same(group_name): pprint("Ended with grpup name; stop moving down, break") @@ -272,7 +260,7 @@ class Parser: weeknum = 2 continue - pprint(weekday) + pprint(weekday) weekday_key_name = weekday + ("_1" if weeknum == 1 else "_2") self.push_weekday_meta(weekday, weeknum, weekday_key_name, weekday_mr) @@ -316,6 +304,7 @@ class Parser: pair_num_to_add = pair_num_mr.cell(self.reader).value.replace(" ", "").strip() if len(pair_num_to_add) == 0: pair_num_to_add = "???" + pprint("Составители эксельки? Вы почему не указали номер пары ёклмн") pairs.add(pair_num_to_add) for cell in dirty_line: @@ -351,7 +340,6 @@ class Parser: pair_name = sorted(pairs)[0] except: pass - today = slots[w] obj = { "object": "event", "pairs": sorted(pairs), @@ -364,10 +352,29 @@ class Parser: if len(times) > 0: obj['times'] = times + def smart_insert(first_dict, key, to_insert): + 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: + 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: - slots[w] = obj + smart_insert(slots, w, obj) + else: - today[pair_name] = obj + smart_insert(slots[w], pair_name, obj) + # here may be a empty all_raw clean_state() first_coord = None @@ -397,13 +404,13 @@ def parse_groups(reader: "ExcelSheetReader", head, col_start, head_rx): name = utils.unspace(x) skip = False if "-" not in name: - for x in bad_group_names: + for x in BAD_GROUP_NAMES: if x in name.lower(): skip = True pprint(f"Skip groupname {name} because not dash in name and in blacklist") if not skip: - groups[name] = { + groups[name.lower()] = { "name": name, "position": [head_rx, i], "width": merged.width(), diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2b34fa0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +openpyxl +xlrd +beautifulsoup4 +requests \ No newline at end of file diff --git a/translations.py b/translations.py index 4211e85..4c7466d 100644 --- a/translations.py +++ b/translations.py @@ -1,7 +1,7 @@ -# --- Абстрактный базовый класс (Контракт) --- +# Copyright Stanislav Mironov from abc import ABC, abstractmethod -from datetime import datetime, time +from datetime import time import openpyxl import xlrd @@ -27,6 +27,7 @@ class TranschendentnostCell: def is_empty(self): return self._is_empty +# --- Абстрактный базовый класс (Контракт) --- class ExcelSheetReader(ABC): """ Абстрактный базовый класс, определяющий интерфейс для чтения данных из листа Excel. @@ -130,7 +131,6 @@ class ExcelSheetReader(ABC): # --- Реализация №1: Обертка для xlrd --- - class XlrdSheetReader(ExcelSheetReader): def __init__(self, file_path, sheet_index=0): super().__init__(file_path) @@ -219,7 +219,6 @@ class XlrdSheetReader(ExcelSheetReader): # --- Реализация №2: Обертка-транслятор для openpyxl --- - class OpenpyxlSheetReader(ExcelSheetReader): def __init__(self, file_path, sheet_name=None): super().__init__(file_path) @@ -309,8 +308,7 @@ class OpenpyxlSheetReader(ExcelSheetReader): return [] -# --- Фабричная функция (Ваша единственная точка входа) --- - +# --- Фабричная функция (единственная точка входа) --- def create_reader(file_path, **kwargs) -> ExcelSheetReader: """ Создает и возвращает подходящий экземпляр ридера в зависимости от расширения файла. diff --git a/utils.py b/utils.py index 20c43ed..29b515b 100644 --- a/utils.py +++ b/utils.py @@ -1,12 +1,82 @@ - # Copyright Stanislav Mironov import time import xlrd -from coord import Coord, Merged +from coord import Coord 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: def __init__(self): @@ -69,58 +139,6 @@ def parse_all_dirt(reader: "ExcelSheetReader", min_pos: Coord, right, down, with 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): """ Преобразует координаты строки и столбца (начиная с 0) в эквивалент Excel (например, A7, CB34). @@ -179,5 +197,6 @@ def weekday_to_num(st: str): if st.upper().strip().startswith("ВОСКР"): return 7 + print(f"Unknown weekday num for str: {st}; returnted -1") return -1 \ No newline at end of file diff --git a/vstu_parser_result_scheme.json b/vstu_parser_result_scheme.json deleted file mode 100644 index 3db3d63..0000000 --- a/vstu_parser_result_scheme.json +++ /dev/null @@ -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 -} \ No newline at end of file