From 832c2666c3ac8ab79e76d9ea10aa6a57bffef08b Mon Sep 17 00:00:00 2001 From: FazziCLAY Date: Thu, 18 Sep 2025 20:05:57 +0300 Subject: [PATCH 01/14] Economy resources, sorts and latest_changes --- .gitignore | 1 + links_parser.py | 39 ++++++++++++++++----------- main.py | 71 ++++++++++++++++++++++++------------------------- 3 files changed, 60 insertions(+), 51 deletions(-) diff --git a/.gitignore b/.gitignore index 99010fe..2dec1e4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__ .idea result*.json groups.json +diffable_dates.txt \ No newline at end of file diff --git a/links_parser.py b/links_parser.py index 3553032..584a02f 100644 --- a/links_parser.py +++ b/links_parser.py @@ -10,6 +10,11 @@ from bs4 import BeautifulSoup BASE_URL = "https://www.vstu.ru/" RASP_PREFIX = "https://www.vstu.ru/student/raspisaniya/zanyatiy/index.php?dep=" +def sibling_clear_to_date(s: str): + if s is None: + return "!!!Python None!!!" + return s.lower().replace("(последнее изменение:", "").replace(")", "").strip() + # Парсит ссылки на эксель .xls & .xlsx файлы и выдаёт их def parse_links(facultets): session = requests.Session() @@ -27,7 +32,7 @@ def parse_links(facultets): } ) - EXCEL_LINKS = {} + EXCEL_LINKS = [] for facultet in facultets: url = RASP_PREFIX + facultet print("getting...") @@ -38,19 +43,23 @@ def parse_links(facultets): # Ищем все теги , у которых атрибут href соответствует нашему паттерну excel_tags = soup.find_all('a', href=excel_pattern) - excel_links = [tag.get('href') for tag in excel_tags] - - # Предположим, вы уже получили excel_links из одного из методов выше - # excel_links = ['../../../upload/raspisanie/z/ОН_ХТФ_1 курс.xlsx', ...] - - absolute_links = [urljoin(BASE_URL, relative_link) for relative_link in excel_links] - - if facultet not in EXCEL_LINKS.keys(): - EXCEL_LINKS[facultet] = set() - - for excel_url in absolute_links: - EXCEL_LINKS[facultet].add(excel_url) - print(f"+url {excel_url}") + for a in excel_tags: + last_changed = sibling_clear_to_date(a.next_sibling) + url = urljoin(BASE_URL, a.get('href')) + record = { + "facultet": facultet, + "url": url, + "last_changed": last_changed + } + print(record) + EXCEL_LINKS.append(record) - return EXCEL_LINKS + 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 f78a644..4f6fa2c 100644 --- a/main.py +++ b/main.py @@ -19,10 +19,11 @@ import shutil def currt(): return round(time.time()) -FACULTETS = [ +FACULTETS = sorted([ "asp", "mag", "fastiv", "fat", "ftkm", "ftpp", "feu", "fevt", "htf", "vkf", "mmf", "fpik" -] +]) DIRNAME = "excels" +DIFFABLE_DATES = "diffable_dates.txt" DEBUG_ONE_FAC = None #'htf' result_groups = {} @@ -63,20 +64,15 @@ result = { "see_header_at_top_of_this_file": "SEE TOP OF THIS FILE | ОБРАТИТЕ ВНИМАНИЕ НА ВЕРХ ЭТОГО ФАЙЛА" } -def process_excel_file(facultet, excel_url, counter, timeid): +def process_excel_file(facultet, excel_url, counter, latest_changed): is_xlsx = excel_url.endswith(".xlsx") - filename = f"{DIRNAME}/" + timeid + f"_[C{counter}]_" + facultet + ".xls" + ("x" if is_xlsx else "") + filename = f"{DIRNAME}/" + f"_[C{counter}]_" + facultet + ".xls" + ("x" if is_xlsx else "") excel_info = { "filename": excel_url.split("/")[-1], "url": excel_url, + "latest_changed": latest_changed, "download_place": filename, - "stat": { - "download": -1, - "create_reader": -1, - "parse": -1, - "cycles": 0 - }, "group_names_parsed": [], "facultet": facultet, "counter": counter @@ -84,17 +80,12 @@ def process_excel_file(facultet, excel_url, counter, timeid): parser.LOGGING = False try: - t = utils.StepTimeCounter() aigenerated.download_file_from_url(excel_url, filename) - excel_info["stat"]['download'] = t.step() - reader = translations.create_reader(filename) print("Reader info") print(reader.info()) - excel_info["stat"]['create_reader'] = t.step() - + while True: - excel_info['stat']['cycles'] += 1 print(f"Parsing sheet №{reader.get_sheet_index()+1} (from 1)") prs = parser.Parser(reader) @@ -103,7 +94,7 @@ def process_excel_file(facultet, excel_url, counter, timeid): print("parsed done!") if prs.parser_error is not None: - excel_info["parser_error_cycle_" + str(excel_info['stat']['cycles'])] = prs.parser_error + excel_info["parser_error_cycle_" + str(reader.get_sheet_index()+1)] = prs.parser_error for group_name in prs.groups.keys(): if group_name in result_groups.keys(): @@ -121,8 +112,6 @@ def process_excel_file(facultet, excel_url, counter, timeid): gr['facultet'] = facultet gr['data_source'] = excel_url.split("/")[-1] gr['debug'] = { - "counter": counter, - "timeid": timeid, "excel_url": excel_url, "reader_info": reader.info(), "reader_sheet_index": reader.get_sheet_index(), @@ -138,9 +127,6 @@ def process_excel_file(facultet, excel_url, counter, timeid): else: reader.next_sheet() print("Next sheet!") - - excel_info["stat"]['parse'] = t.step() - except Exception as e: print(f"Error while {excel_url}") @@ -164,6 +150,7 @@ def process_excel_file(facultet, excel_url, counter, timeid): faileds = [] def main(): + global result_groups, result t = utils.StepTimeCounter() try: try: @@ -179,22 +166,34 @@ def main(): print("main(); parse links starting...") EXCEL_LINKS = links_parser.parse_links(FACULTETS if DEBUG_ONE_FAC is None else [DEBUG_ONE_FAC]) - counter = 0 - timeid = str(round(time.time())) - for facultet in EXCEL_LINKS.keys(): - counter += 1000 - print(f"\n\n-- Факультет '{facultet}' --") - facultet_urls = EXCEL_LINKS[facultet] - for excel_url in facultet_urls: - counter += 1 - print(f"\n\n-- Ссылка --") - print(f"{excel_url}") - - print("Start processing excel file") - process_excel_file(facultet, excel_url, counter, timeid) - print("Excel file processing done!") + 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) + + if now_diffable_dates == prev_diffable_dates: + print("No date changes in vstu.ru website. Stopping") + return + + 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") + group_names_alphabeticaly = sorted(result_groups.keys()) + sorted_groups = {} + for group_name in group_names_alphabeticaly: + sorted_groups[group_name] = result_groups[group_name] + + result['groups'] = sorted_groups result['stat']['total_parsing_time'] = t.step() From cee8b64eafbad8a6b249e90ae318f72288a3a92c Mon Sep 17 00:00:00 2001 From: FazziCLAY Date: Sun, 5 Oct 2025 13:55:14 +0300 Subject: [PATCH 02/14] fix support table relative horizontally --- parser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parser.py b/parser.py index 4a3f5c8..060a3e1 100644 --- a/parser.py +++ b/parser.py @@ -81,8 +81,8 @@ class Parser: pos = Coord(row, group['position'][1]) # текущая позиция, верхний левый угол (=low) pprint(f"while pos={pos}") pos_right = pos.shift(right=3) - pair_pos = pos.replace(col=5) - weekday_pos = pos.replace(col=4) + pair_pos = pos.replace(col=monday.col + 1) + weekday_pos = pos.replace(col=monday.col) merged = self.reader.get_merged_coord(pos) merged_cell = merged.cell(self.reader) cv = merged_cell.value From 777fae4276a203c701fb1bdd7ade986263155e49 Mon Sep 17 00:00:00 2001 From: FazziCLAY Date: Sun, 5 Oct 2025 14:19:59 +0300 Subject: [PATCH 03/14] Added left calendar dates parsing --- hashes.py | 34 ++++++++++++++++++++++++ main.py | 36 +++++++++++++++++--------- parser.py | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++----- utils.py | 2 +- 4 files changed, 130 insertions(+), 19 deletions(-) create mode 100644 hashes.py diff --git a/hashes.py b/hashes.py new file mode 100644 index 0000000..fe847fe --- /dev/null +++ b/hashes.py @@ -0,0 +1,34 @@ +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/main.py b/main.py index 4f6fa2c..2b33f81 100644 --- a/main.py +++ b/main.py @@ -15,6 +15,7 @@ import utils import json import links_parser import shutil +import hashes def currt(): return round(time.time()) @@ -25,7 +26,7 @@ FACULTETS = sorted([ DIRNAME = "excels" DIFFABLE_DATES = "diffable_dates.txt" -DEBUG_ONE_FAC = None #'htf' +DEBUG_ONE_FAC = None #'fevt' result_groups = {} result = { "version": 1, @@ -42,14 +43,14 @@ result = { "total_parsing_time": -1, }, "api_notices": { - "updated_at": 1757688552, - "text": "Пожалуйста сохраняйте 'updated_at', это время изменения ЭТОГО текста. Тут возможно будут появлятся важные BREAKING CHANGES и дедлайны к ним.\nПо хорошему если updated_at другой по сравнению с вашем кэшем это сообщение должно отправляться вам в телеграм как уведомление о поедстоящих изменениях\nwarning=True значит 'text' содержит важное а не как щас hint.\n\n ~fazziclay aka Stanislav;", + "updated_at": 1759651871, + "text": "Пожалуйста сохраняйте '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 скачанный эксель файл.", "warning": False, "tut-plavayuschaya-struktura": "required only 'updated_at', 'text' and 'warning'" }, "doubled_groups": [], "debug": { - "bleu~~": 1 + "bleu~~": 2 }, "excels": [], "facultets": FACULTETS, @@ -66,22 +67,28 @@ result = { def process_excel_file(facultet, excel_url, counter, latest_changed): is_xlsx = excel_url.endswith(".xlsx") - filename = f"{DIRNAME}/" + f"_[C{counter}]_" + facultet + ".xls" + ("x" if is_xlsx else "") + download_place = f"{DIRNAME}/" + f"_[C{counter}]_" + facultet + ".xls" + ("x" if is_xlsx else "") + + excel_filename = excel_url.split("/")[-1] excel_info = { - "filename": excel_url.split("/")[-1], + "filename": excel_filename, + "data_source_hash": None, "url": excel_url, "latest_changed": latest_changed, - "download_place": filename, + "download_place": download_place, "group_names_parsed": [], "facultet": facultet, - "counter": counter + "counter": counter, + "week_keys_metadata": {} } parser.LOGGING = False try: - aigenerated.download_file_from_url(excel_url, filename) - reader = translations.create_reader(filename) + 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()) @@ -95,6 +102,9 @@ def process_excel_file(facultet, excel_url, counter, latest_changed): print("parsed done!") if prs.parser_error is not None: excel_info["parser_error_cycle_" + str(reader.get_sheet_index()+1)] = prs.parser_error + + if prs.parser_warnings is not None and len(prs.parser_warnings) > 0: + excel_info["parser_warnings_cycle_" + str(reader.get_sheet_index()+1)] = prs.parser_warnings for group_name in prs.groups.keys(): if group_name in result_groups.keys(): @@ -110,14 +120,16 @@ def process_excel_file(facultet, excel_url, counter, latest_changed): gr = result_groups[group_name] = prs.groups[group_name] gr['facultet'] = facultet - gr['data_source'] = excel_url.split("/")[-1] + gr['data_source'] = excel_filename # same as 'filename' in excel_info's + gr['data_source_hash'] = sha1hash gr['debug'] = { "excel_url": excel_url, "reader_info": reader.info(), "reader_sheet_index": reader.get_sheet_index(), - "filename": filename + "download_place": download_place } excel_info["group_names_parsed"].append(group_name) + excel_info['week_keys_metadata'] = prs.week_keys_metadata print(f"Populates {len(prs.groups)} groups to result: " + " ".join(prs.groups.keys())) diff --git a/parser.py b/parser.py index 060a3e1..9c4952a 100644 --- a/parser.py +++ b/parser.py @@ -10,8 +10,9 @@ import aigenerated from coord import Coord, Merged from translations import ExcelSheetReader import utils +from collections import defaultdict -LOGGING = True +LOGGING = False def pprint(*args, **kwargs): if LOGGING: @@ -21,9 +22,11 @@ class Parser: def __init__(self, reader: ExcelSheetReader): self.reader = reader self.groups = {} - self.teachers = set() - self.places = set() + self.week_keys_metadata = {} + + self.weeknums: defaultdict = defaultdict(set) # no support json! self.parser_error = None + self.parser_warnings = [] pprint("Parser created for '{0}'".format(reader.info())) def parse(self): @@ -34,6 +37,11 @@ class Parser: self.parser_error = "'ПОНЕДЕЛЬНИК' не найден в таблице." return + if monday.col != 4: + print("--- warning parse! ---") + print(f"Monday col != 4 (actual: {monday})") + self.parser_warnings.append(f"Monday col != 4 (actual: {monday}); Это, наверное, может работать не стабильно!") + head_rx = monday.row - 1 # выше первого понидельника if head_rx < 0: raise Exception("head_rx < 0: Программа пыталась найти 'ПОНЕДЕЛЬНИК', но по всей видимости не нашла.") @@ -49,8 +57,62 @@ class Parser: pprint("\nSTART OF PROCESS GROUP\n") self.process_group(group, monday) pprint("\nEND OF PROCESS GROUP\n") + + self.process_weekmetadatas(monday) - pprint(self.teachers) + def process_weekmetadatas(self, first_monday: "Coord"): + for x in self.weeknums.keys(): + pprint(x) + set_of_merged: set = self.weeknums[x] + l = len(set_of_merged) + if l != 1: + self.week_keys_metadata[x] = { + "error": True, + "error_text": f"Parse error: count of found '{x}' (need view like WEEKDAY_1; weekday - in r; 1 - weeknum[1, 2]) is {l}; required only one!" + } + self.parser_warnings.append(f"Processing weekmetadata for '{x}' failed because count of uniqie merged cells not one (actual: {l}). :<") + continue + + weekday_merged: Merged = set_of_merged.pop() + if weekday_merged.width() != 1: + self.week_keys_metadata[x] = { + "error": True, + "error_text": f"Weekday excel block width != 1 (actual {weekday_merged.width()})" + } + self.parser_warnings.append(f"Processing weekmetadata for '{x}' failed because weekday excel block width != 1 (actual {weekday_merged.width()})") + continue + + month_row = first_monday.row - 1 + curr_col = weekday_merged.low.col - 1 + while curr_col >= 0: + month_pos = Coord(month_row, curr_col) + month_cell = month_pos.cell(self.reader) + if month_cell.is_empty(): + pprint("month cell is empty") + break + month_name = str(month_cell.value).strip() + pprint(month_cell) + all_nums_of_month = utils.parse_all_dirt(self.reader, month_pos.shift(down=1), right=1, down=weekday_merged.height()) + + if (x not in self.week_keys_metadata.keys()): + self.week_keys_metadata[x] = {} + + if (month_name not in self.week_keys_metadata[x].keys()): + self.week_keys_metadata[x][month_name] = [] + + for x2 in all_nums_of_month: + m = self.week_keys_metadata[x][month_name] + if x2 not in m: + try: + m.append(str(x2).replace(".0", "")) + except: + m.append(x2) + + curr_col -= 1 + + + def push_weekday_meta(self, weekday: str, weeknum: int, week_key_name: str, merged: "Merged"): + self.weeknums[week_key_name].add(merged) def parse_potokoviy(self, merged: Merged): speaker = None @@ -66,7 +128,7 @@ class Parser: return {"loc": str(location).strip(), "leader": str(speaker).strip(), "name": str(merged.cell(self.reader).value).strip()} - def process_group(self, group, monday): + def process_group(self, group: dict, monday: Coord): """ Обработать группы, выполняется для каждой группы, после того как они распарены (parse_groups) group = {'name': 'ИВТ-260', 'position': [5, 6], 'position_human': 'G6:J6'} @@ -109,6 +171,9 @@ class Parser: if not skip: next = 3 # на сколько пыгнуть для следующего шага? + weekday_key_name = weekday + ("_1" if weeknum == 1 else "_2") + self.push_weekday_meta(weekday, weeknum, weekday_key_name, weekday_mr) + is_empty_lesson = len(utils.parse_all_dirt(self.reader, pos, 4, 3)) == 0 # если в поле не найдено ничего.. parsed_discipline_name = None parsed_location = None @@ -182,7 +247,7 @@ class Parser: # если не пустой предмет то записываем его if not is_empty_lesson: slots = group['slots'] - w = weekday + ("_1" if weeknum == 1 else "_2") + w = weekday_key_name if w not in slots.keys(): slots[w] = {} diff --git a/utils.py b/utils.py index 7fc2cb2..b91968f 100644 --- a/utils.py +++ b/utils.py @@ -53,7 +53,7 @@ def remove_from_list(l: list, todel: list): return l -def parse_all_dirt(reader: "ExcelSheetReader", min_pos, right, down): +def parse_all_dirt(reader: "ExcelSheetReader", min_pos: Coord, right, down): RET = set() row = min_pos.row From b71a35341bb1ea0a6d7d7c3fc68ba5e8d8b77ec4 Mon Sep 17 00:00:00 2001 From: FazziCLAY Date: Sun, 5 Oct 2025 14:25:57 +0300 Subject: [PATCH 04/14] fix: no add month name if it parsed in month day slot --- parser.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/parser.py b/parser.py index 9c4952a..36ca257 100644 --- a/parser.py +++ b/parser.py @@ -101,6 +101,10 @@ class Parser: self.week_keys_metadata[x][month_name] = [] for x2 in all_nums_of_month: + if x2.lower() == month_name.lower(): + pprint(f"Skip {x2} month number because it == month_name") + continue + m = self.week_keys_metadata[x][month_name] if x2 not in m: try: From 7d652485bd66a2eceb35cf9f5039855d52051319 Mon Sep 17 00:00:00 2001 From: FazziCLAY Date: Wed, 15 Oct 2025 15:08:33 +0300 Subject: [PATCH 05/14] add scheme doc --- vstu_parser_result_scheme.json | 81 ++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 vstu_parser_result_scheme.json diff --git a/vstu_parser_result_scheme.json b/vstu_parser_result_scheme.json new file mode 100644 index 0000000..3db3d63 --- /dev/null +++ b/vstu_parser_result_scheme.json @@ -0,0 +1,81 @@ +{ + "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 From 7ffb53a8ad3fa81f80f774c690b1522c859fb4e0 Mon Sep 17 00:00:00 2001 From: FazziCLAY Date: Tue, 21 Oct 2025 07:31:33 +0300 Subject: [PATCH 06/14] FIX: repeated monthnums in all 2weeks --- parser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parser.py b/parser.py index 36ca257..220c92e 100644 --- a/parser.py +++ b/parser.py @@ -92,8 +92,8 @@ class Parser: break month_name = str(month_cell.value).strip() pprint(month_cell) - all_nums_of_month = utils.parse_all_dirt(self.reader, month_pos.shift(down=1), right=1, down=weekday_merged.height()) - + all_nums_of_month = utils.parse_all_dirt(self.reader, month_pos.replace(row=weekday_merged.low.row), right=1, down=weekday_merged.height()) + pprint(f"all_nums_of_month={all_nums_of_month}") if (x not in self.week_keys_metadata.keys()): self.week_keys_metadata[x] = {} From 2105e9bc369abdaa34a675e137ae1f0e50ec1b0e Mon Sep 17 00:00:00 2001 From: FazziCLAY Date: Thu, 12 Mar 2026 15:38:44 +0300 Subject: [PATCH 07/14] fix: remove uuid from EMPTY_IN_EXCEL --- parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parser.py b/parser.py index 220c92e..fc73df1 100644 --- a/parser.py +++ b/parser.py @@ -217,7 +217,7 @@ class Parser: previous_dump = previous_pair if fuck_empty_pair_in_excel: if previous_pair is None or previous_pair == "": - pair = f"EMPTY_IN_EXCEL_{uuid.uuid4()}" + pair = f"EMPTY_IN_EXCEL" else: pair = utils.next_element(PAIR_NUMS, previous_pair) From 7e0e4a0b714059eff0d80bd6ce9cbf7de9c741f4 Mon Sep 17 00:00:00 2001 From: FazziCLAY Date: Mon, 16 Mar 2026 20:53:42 +0300 Subject: [PATCH 08/14] refactor: big, more patterns\n\nBREAKING CHANGES --- Dockerfile | 0 main.py | 131 ++++++++------ parser.py | 453 +++++++++++++++++++++++++++++------------------- translations.py | 63 ++++++- utils.py | 13 +- 5 files changed, 416 insertions(+), 244 deletions(-) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py index 2b33f81..fbd80ed 100644 --- a/main.py +++ b/main.py @@ -5,6 +5,7 @@ import json import os +import random import time import traceback import uuid @@ -26,50 +27,75 @@ FACULTETS = sorted([ DIRNAME = "excels" DIFFABLE_DATES = "diffable_dates.txt" +SKIP_DIFFABLE_DATES = True + DEBUG_ONE_FAC = None #'fevt' -result_groups = {} +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", - "daypicture": "QwQ", - "daycite": "running on a rope", + "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": 1759651871, - "text": "Пожалуйста сохраняйте '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 скачанный эксель файл.", - "warning": False, + "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'" }, - "doubled_groups": [], "debug": { - "bleu~~": 2 + "bleu~~": 3 }, "excels": [], "facultets": FACULTETS, - - "emptykey1": "", - "emptykey2": "", - - "groups": result_groups, - - "emptykey3": "", - "emptykey4": "", + "group_names_parsed": [], + "unique_raws": unique_raws, "see_header_at_top_of_this_file": "SEE TOP OF THIS FILE | ОБРАТИТЕ ВНИМАНИЕ НА ВЕРХ ЭТОГО ФАЙЛА" } +def process_obj(data): + 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, @@ -80,9 +106,9 @@ def process_excel_file(facultet, excel_url, counter, latest_changed): "group_names_parsed": [], "facultet": facultet, "counter": counter, - "week_keys_metadata": {} + "sheets": [] } - parser.LOGGING = False + parser.LOGGING = LOGGING try: aigenerated.download_file_from_url(excel_url, download_place) @@ -94,44 +120,45 @@ def process_excel_file(facultet, excel_url, counter, latest_changed): while True: 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) prs = parser.Parser(reader) print("Parser created; parser.parse();") prs.parse() print("parsed done!") + + if len(prs.raw_no_schedule) > 0: + sheet_dict["raw_no_schedule"] = prs.raw_no_schedule + + if len(prs.features) > 0: + sheet_dict["features"] = sorted(prs.features) + if prs.parser_error is not None: - excel_info["parser_error_cycle_" + str(reader.get_sheet_index()+1)] = prs.parser_error + sheet_dict["parser_error"] = prs.parser_error if prs.parser_warnings is not None and len(prs.parser_warnings) > 0: - excel_info["parser_warnings_cycle_" + str(reader.get_sheet_index()+1)] = prs.parser_warnings + sheet_dict["parser_warnings"] = prs.parser_warnings for group_name in prs.groups.keys(): - if group_name in result_groups.keys(): - print(f" -- WTF -- Doubled groups -- name: {group_name}") - if 'warning_doubled_groups_skip' not in excel_info.keys(): - excel_info['warning_doubled_groups_skip'] = [] - - excel_info['warning_doubled_groups_skip'].append(group_name) - result['doubled_groups'].append(group_name) - - - continue - - gr = result_groups[group_name] = prs.groups[group_name] - gr['facultet'] = facultet - gr['data_source'] = excel_filename # same as 'filename' in excel_info's - gr['data_source_hash'] = sha1hash - gr['debug'] = { - "excel_url": excel_url, - "reader_info": reader.info(), - "reader_sheet_index": reader.get_sheet_index(), - "download_place": download_place - } + 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) - excel_info['week_keys_metadata'] = prs.week_keys_metadata + result["group_names_parsed"].append(group_name) + result['stat']['groups'] += 1 + sheet_dict['week_keys_metadata'] = prs.week_keys_metadata + sheet_dict['groups'][group_name] = gr + process_obj(gr['slots']) - print(f"Populates {len(prs.groups)} groups to result: " + " ".join(prs.groups.keys())) + print(f"Populates {len(prs.groups)} groups: " + " ".join(prs.groups.keys())) if not reader.has_next_sheet(): print("File ended") @@ -159,10 +186,12 @@ def process_excel_file(facultet, excel_url, counter, latest_changed): }) result['excels'].append(excel_info) + k = "fine" if len(excel_info['group_names_parsed']) > 0 else "bad" + result['stat']['excels'][k] += 1 faileds = [] def main(): - global result_groups, result + global result t = utils.StepTimeCounter() try: try: @@ -189,7 +218,9 @@ def main(): if now_diffable_dates == prev_diffable_dates: print("No date changes in vstu.ru website. Stopping") - return + if not SKIP_DIFFABLE_DATES: + return + print("SKIP_DIFFABLE_DATES is True, force resuming") counter = 10000 for excel_link in EXCEL_LINKS: @@ -200,14 +231,8 @@ def main(): process_excel_file(facultet, excel_url, counter, latest_changed) print("Saving result.json") - group_names_alphabeticaly = sorted(result_groups.keys()) - sorted_groups = {} - for group_name in group_names_alphabeticaly: - sorted_groups[group_name] = result_groups[group_name] - - result['groups'] = sorted_groups - 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") diff --git a/parser.py b/parser.py index fc73df1..649fdbe 100644 --- a/parser.py +++ b/parser.py @@ -3,10 +3,21 @@ PAIR_NUMS = [ "1-2", "3-4", "5-6", "7-8", "9-10", "11-12", "13-14", "15-16" ] +WEEKDAYS_STARTSWITH = [ + "понед", + "вторник", + "среда", + "четверг", + "пятница", + "суббота" +] +bad_group_names = [ + "янв", "февр", "март", "апр", "май", "сент", "окт", "ноя", "дек", "июнь", "июль", "авг" +] + +from datetime import time import json -import uuid -import aigenerated from coord import Coord, Merged from translations import ExcelSheetReader import utils @@ -17,50 +28,127 @@ LOGGING = False def pprint(*args, **kwargs): if LOGGING: print(*args, **kwargs) + +def is_weeknum(text): + for wd in WEEKDAYS_STARTSWITH: + if text.strip().replace(" ", "").lower().startswith(wd): + return True + return False + +def is_pair(text): + for p in PAIR_NUMS: + if text.strip().replace(" ", "").lower().startswith(p): + return True + return False class Parser: def __init__(self, reader: ExcelSheetReader): self.reader = reader - self.groups = {} - self.week_keys_metadata = {} + self.groups = {} # Группы которые удалось распарсить + self.features = set() # фичи данной страницы + self.week_keys_metadata = {} # календарик + self.schedule_range_row = None # [min, max] диапазон col включительно где расписание + self.raw_no_schedule = [] # всё что не schedule_range_row отправляется сюда ('СОГЛАСОВАНО:', etc..) - self.weeknums: defaultdict = defaultdict(set) # no support json! - self.parser_error = None - self.parser_warnings = [] + self.weeknums: defaultdict = defaultdict(set) # no support json! (для week_keys_metadata) + self.parser_error = None # ошибка парсера перед выходом + self.parser_warnings = [] # предупреждения парсера pprint("Parser created for '{0}'".format(reader.info())) def parse(self): - monday = self.reader.find("ПОНЕДЕЛЬНИК") - if monday is None: + # Характерные признаки разных сеток + no_pair_numeration = False + col_distance_pair_weekday = None + weekday_firstly_calendar = False + + first_weekday = self.reader.find_any(WEEKDAYS_STARTSWITH, startswith=True, nospace=True) + + if first_weekday is None: + self.features.add("no_weekdays") print(" -- Failed parse! -- ") - print("ПОНЕДЕЛЬНИК НЕ НАЙДЕН!") - self.parser_error = "'ПОНЕДЕЛЬНИК' не найден в таблице." + print("дни недели не найдены!") + self.parser_error = f"{WEEKDAYS_STARTSWITH} ни один найден в таблице. Дня недели нет." return - if monday.col != 4: - print("--- warning parse! ---") - print(f"Monday col != 4 (actual: {monday})") - self.parser_warnings.append(f"Monday col != 4 (actual: {monday}); Это, наверное, может работать не стабильно!") + pair_num_any = self.reader.find_any(PAIR_NUMS, nospace=True) + if pair_num_any is None: + no_pair_numeration = True + self.features.add("no_pair_numeration") + self.parser_warnings.append(f"Нет нумерации академических часов {PAIR_NUMS}") + + else: + self.features.add("pair_numeration") + col_distance_pair_weekday = pair_num_any.col - first_weekday.col - head_rx = monday.row - 1 # выше первого понидельника + head_rx = first_weekday.row - 1 # выше первого понидельника + group_col_start = first_weekday.col + 2 + if col_distance_pair_weekday is not None: + if col_distance_pair_weekday > 1: + weekday_firstly_calendar = True + self.features.add("weekdays_before_calendar") + group_col_start = pair_num_any.col + 1 + if head_rx < 0: - raise Exception("head_rx < 0: Программа пыталась найти 'ПОНЕДЕЛЬНИК', но по всей видимости не нашла.") + raise Exception("head_rx < 0: Программа пыталась найти день недели, но по всей видимости не нашла.") head = self.reader.get_row_values(head_rx) # get all ROW (months, groups) pprint(f"head={head}") - self.groups = parse_groups(self.reader, head, monday, head_rx) # parse groups to self.groups + + head_joined = " ||| ".join([v for v in head if isinstance(v, str) and v.strip()]) + 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 + 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") + + self.groups = parse_groups(self.reader, head, group_col_start, head_rx) # parse groups to self.groups pprint(f'self.groups={json.dumps(self.groups, indent=2, ensure_ascii=False)}') - + pprint("\n\n\n") for group in self.groups.values(): pprint("\nSTART OF PROCESS GROUP\n") - self.process_group(group, monday) + self.process_group(group, first_weekday, pair_num_any.col if pair_num_any else None) pprint("\nEND OF PROCESS GROUP\n") - - self.process_weekmetadatas(monday) - def process_weekmetadatas(self, first_monday: "Coord"): + # week metadatas parse + S = 9999999 + group_min_col = S + group_min_row = S + + for x in self.groups.values(): + p = x['position'] + group_min_row = min(p[0], group_min_row) + group_min_col = min(p[1], group_min_col) + + if group_min_row != S and group_min_col != S: + pprint("Process weekmetadatas!") + self.process_weekmetadatas(Coord(row=group_min_row, col=group_min_col)) + + # parse no-schedule raws (согласовано, и т.д.) + self.parse_raw_no_schedule() + + + def parse_raw_no_schedule(self): + """Распарсить всё за пределами self.schedule_range_row в self.raw_no_schedule""" + if self.schedule_range_row is None: + return + + row = 0 + while row < self.reader.get_row_count(): + if row >= self.schedule_range_row[0] and row <= self.schedule_range_row[1]: + row = self.schedule_range_row[1] + 1 + + row_values = self.reader.get_row_values(row) + row_values = [v for v in row_values if isinstance(v, str) and v.strip()] + if len(row_values) > 0: + self.raw_no_schedule.append(row_values) + + row += 1 + + def process_weekmetadatas(self, first_group: "Coord"): + """Обработать календарик""" for x in self.weeknums.keys(): pprint(x) set_of_merged: set = self.weeknums[x] @@ -82,14 +170,16 @@ class Parser: self.parser_warnings.append(f"Processing weekmetadata for '{x}' failed because weekday excel block width != 1 (actual {weekday_merged.width()})") continue - month_row = first_monday.row - 1 - curr_col = weekday_merged.low.col - 1 + month_row = first_group.row + curr_col = first_group.col - 1 while curr_col >= 0: month_pos = Coord(month_row, curr_col) month_cell = month_pos.cell(self.reader) if month_cell.is_empty(): pprint("month cell is empty") - break + curr_col -= 1 + continue + month_name = str(month_cell.value).strip() pprint(month_cell) all_nums_of_month = utils.parse_all_dirt(self.reader, month_pos.replace(row=weekday_merged.low.row), right=1, down=weekday_merged.height()) @@ -117,6 +207,16 @@ class Parser: def push_weekday_meta(self, weekday: str, weeknum: int, week_key_name: str, merged: "Merged"): self.weeknums[week_key_name].add(merged) + + def row_with_schedule_notify(self, row_coord): + if self.schedule_range_row is None: + self.schedule_range_row = [row_coord, row_coord] + + if self.schedule_range_row[1] < row_coord: + self.schedule_range_row[1] = row_coord + + if self.schedule_range_row[0] > row_coord: + self.schedule_range_row[0] = row_coord def parse_potokoviy(self, merged: Merged): speaker = None @@ -132,163 +232,157 @@ class Parser: return {"loc": str(location).strip(), "leader": str(speaker).strip(), "name": str(merged.cell(self.reader).value).strip()} - def process_group(self, group: dict, monday: Coord): + def process_group(self, group: dict, first_weekday: Coord, pair_pos_col): """ Обработать группы, выполняется для каждой группы, после того как они распарены (parse_groups) group = {'name': 'ИВТ-260', 'position': [5, 6], 'position_human': 'G6:J6'} """ pprint(f"process_group group={group}") group_name = group['name'] - pprint(group_name) - row = group['position'][0] + 1 # counter for while, +1 for shift down; также номер строки в таблице (вроде с нуля) + pprint(F"Имя группы: {group_name}") + row_c1 = group['position'][0] + 1 # counter for while, +1 for shift down; также номер строки в таблице (вроде с нуля) + self.row_with_schedule_notify(group['position'][0]) + group_header_pos = Coord(group['position'][0], group['position'][1]) + width = group['width'] weeknum = 1 # номер недели, щёлкнет +1 при каком-то условии. previous_pair = None - while row < self.reader.get_row_count(): # maybe условие чтобы не уйти ниже чем есть строк - pos = Coord(row, group['position'][1]) # текущая позиция, верхний левый угол (=low) - pprint(f"while pos={pos}") - pos_right = pos.shift(right=3) - pair_pos = pos.replace(col=monday.col + 1) - weekday_pos = pos.replace(col=monday.col) - merged = self.reader.get_merged_coord(pos) - merged_cell = merged.cell(self.reader) - cv = merged_cell.value - # В конце (12 пара:>) название группы, можно использовать как якорь - if utils.unspace(cv) == group_name: - pprint("Lesson == group name; ending group loop.") - break + + 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) - weekday_mr = self.reader.get_merged_coord(weekday_pos) - weekday = utils.unspace(weekday_mr.cell(self.reader).value) - pair_mr = self.reader.get_merged_coord(pair_pos) - pair = utils.unspace(pair_mr.cell(self.reader).value) - - skip = 0 - if weekday == "": - if weeknum == 1: - weeknum += 1 - pprint("------") - skip = 1 - row += 1 - else: - break - if not skip: - next = 3 # на сколько пыгнуть для следующего шага? + if pos_c1.cell(self.reader).is_nospace_nocase_same(group_name): + pprint("Ended with grpup name; stop moving down, break") + break + + weekday_pos = pos_c1.replace(col=first_weekday.col) + weekday_cell = weekday_pos.cell(self.reader) + weekday_mr = self.reader.get_merged_coord(weekday_pos) + weekday = weekday_cell.value + + if not is_weeknum(weekday): + row_c1 += 1 + pprint("Not weeknum!") + if weekcycles > 0: + if (weeknum != 2): + pprint("Weeknum now 2") + weekday = 0 + weeknum = 2 + continue + + pprint(weekday) + weekday_key_name = weekday + ("_1" if weeknum == 1 else "_2") + self.push_weekday_meta(weekday, weeknum, weekday_key_name, weekday_mr) + + # state + event_no = 1 + is_widely = False + override_col_range = None + all_raw = set() + pairs = set() + times = [] + first_coord = None + + row_c2 = row_c1 + while row_c2 <= weekday_mr.high.row: + pos_c2 = Coord(row_c2, group['position'][1]) # текущая позиция (внутри группы, внутри дня недели), верхний левый угол (=low) + cell_c2 = pos_c2.cell(self.reader) + mr_c2 = self.reader.get_merged_coord(pos_c2) - weekday_key_name = weekday + ("_1" if weeknum == 1 else "_2") - self.push_weekday_meta(weekday, weeknum, weekday_key_name, weekday_mr) + if first_coord is None: + first_coord = pos_c2.row - is_empty_lesson = len(utils.parse_all_dirt(self.reader, pos, 4, 3)) == 0 # если в поле не найдено ничего.. - parsed_discipline_name = None - parsed_location = None - parsed_leader = None - pairs = 1 - wtf_tomanypairs = False - is_solid = pos_right in merged - parsed_uncotigorized = [] - is_wide_maybe_potokoviy = merged.width() > 4 # потоковая ли лекция (занимает несколько групп.) - - if not is_empty_lesson: - cur = pos.shift(down=2) - while utils.has_no_bottom_border(self.reader, cur): - next += 3 - pairs += 1 - pprint(f"next = {next} cur={cur}") - if pairs >= 7: - wtf_tomanypairs = True - break - cur = cur.shift(down=3) - - if is_wide_maybe_potokoviy: - ret = self.parse_potokoviy(merged) - parsed_location = ret['loc'] - parsed_leader = ret['leader'] - parsed_discipline_name = ret['name'] - parsed_uncotigorized = list(utils.parse_all_dirt(self.reader, merged.low, merged.width(), next)) - - else: - if (is_solid): - parsed_discipline_name = cv - - parsed_uncotigorized = list(utils.parse_all_dirt(self.reader, merged.low, 4, next)) - - # попытка исправить пару (1-2) если пустая. - fuck_empty_pair_in_excel = pair == "" - previous_dump = previous_pair - if fuck_empty_pair_in_excel: - if previous_pair is None or previous_pair == "": - pair = f"EMPTY_IN_EXCEL" - else: - pair = utils.next_element(PAIR_NUMS, previous_pair) + pair_num = None + pair_num_mr = None + if pair_pos_col is not None: + pair_num = pos_c2.replace(col=pair_pos_col) + pair_num_mr = self.reader.get_merged_coord(pair_num) - if pair != "": - previous_pair = pair if next == 3 else None # костыль чтобы избежать гипотетически не верной даты. + if (not is_widely) and (mr_c2.low.col < group_header_pos.col or mr_c2.high.col > group_header_pos.col + width - 1): + is_widely = True + override_col_range = (mr_c2.low.col, mr_c2.high.col) + + col_low = group_header_pos.col + col_high = group_header_pos.col + width - 1 + if override_col_range is not None: + col_low = min(col_low, override_col_range[0]) + col_high = max(col_high, override_col_range[1]) - # пытаемся из некотегорезированных данных выцепить место и лидера (препода) - prepods = set() - if parsed_leader is not None: prepods.add(parsed_leader.strip()) - - locations = set() - if parsed_location is not None: locations.add(parsed_location.strip().replace(" ", "")) - - for x in list(parsed_uncotigorized): - if aigenerated.is_surname_string(x): - prepods.add(x.strip()) - - if aigenerated.is_room_number(x): - locations.add(x.strip().replace(" ", "") if x is not None else None) - - # попытка починить пустую дисциплину - if parsed_discipline_name is None: - l = sorted(utils.remove_from_list(list(parsed_uncotigorized), list(locations | prepods | set([parsed_location, parsed_leader])))) - parsed_discipline_name = " ".join(l) - - # чистим сеты от мусора - utils.discards_list(prepods, nones=True, emptystrings=True) - utils.discards_list(locations, nones=True, emptystrings=True) - utils.discards_list(parsed_uncotigorized, nones=True, emptystrings=True) - - # если не пустой предмет то записываем его - if not is_empty_lesson: - slots = group['slots'] - w = weekday_key_name - if w not in slots.keys(): - slots[w] = {} + dirty_line = utils.parse_all_dirt(self.reader, Coord(row_c2, col_low), (col_high - col_low + 1), 1, with_cells=True) + if len(dirty_line) > 0: + if pair_num_mr is not None: + pair_num_to_add = pair_num_mr.cell(self.reader).value.replace(" ", "").strip() + if len(pair_num_to_add) == 0: + pair_num_to_add = "???" + pairs.add(pair_num_to_add) - today = slots[w] - today[pair] = { - "excel_pos": str(pos), - "discipline_name": parsed_discipline_name.strip(), - "locations": sorted(locations), - "leads": sorted(prepods), - "is_solid": is_solid, - "time_coeff": pairs, - "is_flow": is_wide_maybe_potokoviy, - "lefttopmerged": { - "width": merged.width(), - "height": merged.height(), - "excel_range": utils.merged_humanize(merged.as_numbers()) - }, - "raw": sorted(parsed_uncotigorized), - "weekday": utils.weekday_to_num(weekday), - "weeknum": weeknum - } - if fuck_empty_pair_in_excel: - today[pair]['pair_num_empty'] = { - "prev": previous_dump, - "restored": pair != "", - "pair": pair - } - if wtf_tomanypairs: - today[pair]['to_many_parsing_time_coeff'] = True - + for cell in dirty_line: + if not cell.is_time: + all_raw.add(str(cell.value)) + else: + dt: time = cell.value + times.append(str(dt)) - # INCREMENT на next и конец цикла. - row += next + def clean_state(): + nonlocal is_widely, override_col_range, event_no, all_raw, pairs, times, first_coord + is_widely = False + override_col_range = None + event_no += 1 + all_raw = set() + pairs = set() + first_coord = None + times = [] + + + if not utils.has_no_bottom_border(self.reader, pos_c2) and not(mr_c2.high.row - row_c2 > 0): + if not (len(all_raw) == 0): + # this code last for current state event + pprint(f"№{event_no} {pairs}: {'[wide] ' if is_widely else ''} raw={all_raw}") + + slots = group['slots'] + w = weekday_key_name + if w not in slots.keys(): + slots[w] = {} + + pair_name = "????" + try: + pair_name = sorted(pairs)[0] + except: pass + + today = slots[w] + obj = { + "object": "event", + "pairs": sorted(pairs), + "is_flow": is_widely, + "excel_range": utils.merged_humanize((first_coord, col_low, row_c2, col_high)), + "raw": sorted(all_raw), + "weekday": utils.weekday_to_num(weekday), + "weeknum": weeknum + } + if len(times) > 0: + obj['times'] = times + + if pair_pos_col is None: + slots[w] = obj + else: + today[pair_name] = obj + # here may be a empty all_raw + clean_state() + first_coord = None + + + if row_c2 >= weekday_mr.high.row: + clean_state() + pprint("Last for weekday") + row_c2 += 1 + + row_c1 += weekday_mr.height() + weekcycles += 1 - -def parse_groups(reader: "ExcelSheetReader", head, monday: Coord, head_rx): +def parse_groups(reader: "ExcelSheetReader", head, col_start, head_rx): """Распознать список групп и метаданные к ним, по сути получить список названий группы и координат её верхнего header-а (AQ6:AT6)""" groups = {} i = 0 @@ -296,21 +390,26 @@ def parse_groups(reader: "ExcelSheetReader", head, monday: Coord, head_rx): x = head[i] pprint(f"while i={i} head[i]={x}") merged = reader.get_merged_coord(Coord(head_rx, i)) - if i > monday.col + 1: - if merged is None or x == "": - break - - if merged.width() != 4: - pprint(f"WARNING: group header witdh !=4 (found: {merged.width()}); blocks !=4 not supported by parser.") + if i >= col_start: + if merged is None or x == "" or x is None: break name = utils.unspace(x) - groups[name] = { - "name": name, - "position": [head_rx, i], - "position_human": utils.merged_humanize(merged.as_numbers()), - "slots": {} - } + skip = False + if "-" not in name: + 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] = { + "name": name, + "position": [head_rx, i], + "width": merged.width(), + "position_human": utils.merged_humanize(merged.as_numbers()), + "slots": {} + } if merged is None: i += 1 diff --git a/translations.py b/translations.py index c61b79d..4211e85 100644 --- a/translations.py +++ b/translations.py @@ -1,6 +1,7 @@ # --- Абстрактный базовый класс (Контракт) --- from abc import ABC, abstractmethod +from datetime import datetime, time import openpyxl import xlrd @@ -10,9 +11,18 @@ from coord import Coord, Merged EMPTY_CTYPES = [xlrd.XL_CELL_EMPTY, xlrd.XL_CELL_BLANK] class TranschendentnostCell: - def __init__(self, value, is_empty): + def __init__(self, value, is_empty, is_time=False): self.value = value + self.is_time = isinstance(value, time) or is_time self._is_empty = is_empty + + def is_nospace_nocase_same(self, query): + try: + if self.value.lower().replace(" ", "").strip() == query.lower().replace(" ", "").strip(): + return True + except: pass + + return False def is_empty(self): return self._is_empty @@ -28,6 +38,10 @@ class ExcelSheetReader(ABC): @abstractmethod def get_sheet_index(self): pass + + @abstractmethod + def get_sheet_name(self): + pass @abstractmethod def has_next_sheet(self): @@ -71,16 +85,28 @@ class ExcelSheetReader(ABC): return "TODO: info" @abstractmethod - def cell(self, row, col): + def cell(self, row, col) -> TranschendentnostCell: """Возвращает абстрактную клетку""" pass - def find(self, query = None): + def find(self, query = None, startswith=False, nospace=False): + return self.find_any([query], startswith=startswith, nospace=nospace) + + def find_any(self, query = None, startswith=False, nospace=False): for rx in range(self.get_row_count()): i = 0 for x in self.get_row_values(rx): - if x == query: - return Coord(rx, i) + if nospace: + x = str(x).replace(" ", "").strip() + + for query_selected in query: + if x == query_selected: + return Coord(rx, i) + elif startswith: + try: + if str(x).lower().startswith(query_selected.lower()): + return Coord(rx, i) + except: pass i += 1 return None @@ -117,6 +143,9 @@ class XlrdSheetReader(ExcelSheetReader): def init_sheet(self): self.sheet = self.book.sheet_by_index(self.sheet_index) + + def get_sheet_name(self): + return self.sheet.name def has_next_sheet(self): return self.sheet_index < len(self.book.sheet_names())-1 @@ -140,7 +169,24 @@ class XlrdSheetReader(ExcelSheetReader): def cell(self, row, col): """Возвращает абстрактную клетку""" c = self.sheet.cell(row, col) - return TranschendentnostCell(c.value, c.ctype in EMPTY_CTYPES) + is_empty = c.ctype in EMPTY_CTYPES + is_time = c.ctype == xlrd.XL_CELL_DATE + value = c.value + if is_empty: + value = "" + elif is_time: + if isinstance(value, float): + if value <= 1: + seconds = round(value * 86400) + minutes, seconds = divmod(seconds, 60) + hours, minutes = divmod(minutes, 60) + value = time(hour=hours, second=seconds, minute=minutes) + else: + print(f"TODO: value is {value} its unix? not 0.xxxxxxxx") + else: + is_time = False + print("IsTime but not float!") + return TranschendentnostCell(value, is_empty, is_time=is_time) def get_border_style(self, coord: Coord, side): row = coord.row @@ -192,6 +238,9 @@ class OpenpyxlSheetReader(ExcelSheetReader): def get_sheet_index(self): return self.sheet_index + def get_sheet_name(self): + return self.workbook.sheetnames[self.sheet_index] + def has_next_sheet(self): return self.sheet_index < len(self.workbook.sheetnames)-1 @@ -221,7 +270,7 @@ class OpenpyxlSheetReader(ExcelSheetReader): c = self._get_cell(row, col) is_empty = (c.value is None) - return TranschendentnostCell("" if is_empty else c.value, is_empty) + return TranschendentnostCell("" if is_empty else c.value, is_empty, is_time=isinstance(c.value, time)) def get_cell_value(self, row, col): cell = self._get_cell(row, col) diff --git a/utils.py b/utils.py index b91968f..20c43ed 100644 --- a/utils.py +++ b/utils.py @@ -53,7 +53,7 @@ def remove_from_list(l: list, todel: list): return l -def parse_all_dirt(reader: "ExcelSheetReader", min_pos: Coord, right, down): +def parse_all_dirt(reader: "ExcelSheetReader", min_pos: Coord, right, down, with_cells=False): RET = set() row = min_pos.row @@ -61,10 +61,9 @@ def parse_all_dirt(reader: "ExcelSheetReader", min_pos: Coord, right, down): col = min_pos.col while col < min_pos.col + right: #print(excel_coordinate(row, col)) - cv = reader.get_cell_value(row, col) - value = str(cv).strip() - if cv is not None and len(value) > 0: - RET.add(value) + cv = reader.cell(row, col) + if cv is not None and not cv.is_empty(): + RET.add(cv if with_cells else str(cv.value)) col += 1 row += 1 @@ -165,7 +164,7 @@ def find(sh, query = None): return None def weekday_to_num(st: str): - if st.upper().strip() == "ПОНЕДЕЛЬНИК": + if st.upper().strip().startswith("ПОНЕД"): return 1 if st.upper().strip() == "ВТОРНИК": return 2 @@ -177,7 +176,7 @@ def weekday_to_num(st: str): return 5 if st.upper().strip() == "СУББОТА": return 6 - if st.upper().strip() == "ВОСКРЕСЕНЬЕ": + if st.upper().strip().startswith("ВОСКР"): return 7 return -1 From 1199ce15543c057cdf49975ab272869d29626e09 Mon Sep 17 00:00:00 2001 From: FazziCLAY Date: Wed, 18 Mar 2026 22:15:49 +0300 Subject: [PATCH 09/14] 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 From 80d3c06310476c5e4444e3812a2fae61b2b9ac59 Mon Sep 17 00:00:00 2001 From: FazziCLAY Date: Wed, 18 Mar 2026 22:20:14 +0300 Subject: [PATCH 10/14] feat: check dir before run_session --- main.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/main.py b/main.py index e9f5e36..75a3b8d 100644 --- a/main.py +++ b/main.py @@ -230,10 +230,15 @@ def run_session(): except Exception as e: print(f"Error deleting directory '{DIRNAME}': {e}") +def check_dirs(): + if not os.path.exists(PARSED_DIR): + os.mkdir(PARSED_DIR) def main(): while True: try: + check_dirs() + print("BEGIN run_session();") run_session() print("END run_session();") From 2f799fc198bb789412b03df9fd4c9aecbcfd52fa Mon Sep 17 00:00:00 2001 From: FazziCLAY Date: Wed, 25 Mar 2026 22:27:10 +0300 Subject: [PATCH 11/14] fix: empty line upper; result_v2.json; fallcheck <5 count --- main.py | 31 ++++++++++++++++++++++++++++++- parser.py | 2 +- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index 75a3b8d..4e7d5f3 100644 --- a/main.py +++ b/main.py @@ -26,8 +26,9 @@ DIRNAME = "excels" PARSED_DIR = "parsed" DEBUG_ONE_FAC = None #'fevt' +DEBUG_NO_SAVE_STATES = False -parser.LOGGING = LOGGING = False +parser.LOGGING = LOGGING = True def parse_sheets(download_place): to_return = {} @@ -120,6 +121,8 @@ def load_parsed_state(excel_filename): def save_parsed_state(excel_filename, obj): filepath = parsed_file_path(excel_filename) + if DEBUG_NO_SAVE_STATES: + print("Saved! (fake because DEBUG_NO_SAVE_STATES)") with open(filepath, "w", encoding="utf-8") as fp: json.dump(obj, fp=fp, ensure_ascii=False, sort_keys=True) @@ -149,7 +152,12 @@ def run_session(): print("main(); parse links starting...") EXCEL_LINKS = links_parser.parse_links(FACULTETS if DEBUG_ONE_FAC is None else [DEBUG_ONE_FAC]) + if len(EXCEL_LINKS) < 5 and not DEBUG_ONE_FAC: + raise Exception("Safety exception: excel links count < 5; maybe in vstu.ru tech works") + + last_changeds = set() + states = [] for excel_dict in EXCEL_LINKS: try: last_changeds.add(excel_dict['last_changed']) @@ -179,6 +187,7 @@ def run_session(): try: del state['excel']['different_in_this_session'] except: pass + states.append(state) save_parsed_state(excel_filename, state) continue @@ -195,6 +204,7 @@ def run_session(): state['sheets'] = parse_sheets(download_place) save_parsed_state(excel_filename, state) + states.append(state) except Exception as e: faileds.append({ @@ -222,6 +232,20 @@ def run_session(): "all_files": EXCEL_LINKS, "faileds": faileds }, fp=fp, ensure_ascii=False) + + with open("result_v2.json", 'w', encoding="utf-8") as fp: + all_files = states + json.dump({ + "version": 2, + "notice": "ОТКАЗ ОТ ОТВЕТСТВЕННОСТИ: ПРЕДОСТАВЛЯЕТСЯ КАК-ЕСТЬ (AS-IS) БЕЗ КАКИХ ЛИБО ГАРАНТИЙ", + "contact": "https://fazziclay.com/ или fazziclay@gmail.com", + "api_notices": { + "just_save_and_check_diffs": "просто сохраните и проверяйте разницу" + }, + "actual_at": currt(), + "all_files": sorted(all_files, key=lambda d: d['excel']['url']), + "faileds": faileds + }, fp=fp, ensure_ascii=False) # Delete a non-empty directory and its contents try: @@ -242,6 +266,11 @@ def main(): print("BEGIN run_session();") run_session() print("END run_session();") + + if DEBUG_ONE_FAC: + print("DEBUG_ONE_FAC; break infinity-loop") + break + except Exception as e: print("Exception in run_session();") traceback.print_exception(e) diff --git a/parser.py b/parser.py index ae07fb1..220c450 100644 --- a/parser.py +++ b/parser.py @@ -98,7 +98,7 @@ class Parser: head_joined = " ||| ".join([v for v in head if isinstance(v, str) and v.strip()]) 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 (len(head_joined) == 0) or "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) From 98d413712ecceab19eeb05647f4b6b25e3791808 Mon Sep 17 00:00:00 2001 From: FazziCLAY Date: Thu, 26 Mar 2026 00:12:37 +0300 Subject: [PATCH 12/14] rabbitmq added --- .env | 1 + links_parser.py | 3 ++ main.py | 121 +++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 107 insertions(+), 18 deletions(-) create mode 100644 .env diff --git a/.env b/.env new file mode 100644 index 0000000..416200b --- /dev/null +++ b/.env @@ -0,0 +1 @@ +RABBITMQ_URL=amqp://guest:guest@localhost/ \ No newline at end of file diff --git a/links_parser.py b/links_parser.py index d2c032c..212ba30 100644 --- a/links_parser.py +++ b/links_parser.py @@ -46,9 +46,12 @@ def parse_links(facultets): for a in excel_tags: last_changed = sibling_clear_to_date(a.next_sibling) url = urljoin(BASE_URL, a.get('href')) + disp = a.decode_contents() record = { + "uniqpath": f"vstu.ru/rasp?dep={facultet}/{disp.strip()}", "facultet": facultet, "url": url, + "display_filename": disp, "last_changed": last_changed } print("Found in vstu.ru: ", record) diff --git a/main.py b/main.py index 4e7d5f3..8ad46eb 100644 --- a/main.py +++ b/main.py @@ -4,6 +4,7 @@ import json +import pika import os import random import time @@ -15,6 +16,22 @@ import utils import json import links_parser import shutil +from dotenv import load_dotenv +load_dotenv() + +RABBITMQ_URL = os.environ.get("RABBITMQ_URL") +EXCHANGE_NAME = os.environ.get("RABBITMQ_EXCHANGE", "vstu_schedule") + +try: + connection = pika.BlockingConnection(pika.URLParameters(RABBITMQ_URL)) + channel = connection.channel() + + channel.exchange_declare(exchange=EXCHANGE_NAME, + exchange_type='topic', + durable=True) +except Exception as e: + print("Failed to connect RabbitMQ") + traceback.print_exception(e) def currt(): return round(time.time()) @@ -129,7 +146,6 @@ def save_parsed_state(excel_filename, obj): print(f"Saved parsed state to '{filepath}'") - def run_session(): faileds = [] @@ -158,6 +174,7 @@ def run_session(): last_changeds = set() states = [] + changed = False for excel_dict in EXCEL_LINKS: try: last_changeds.add(excel_dict['last_changed']) @@ -166,7 +183,7 @@ def run_session(): 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(f"Processing {facultet} {excel_filename}") state = load_parsed_state(excel_filename) is_new = state is None @@ -182,6 +199,22 @@ def run_session(): except Exception as e: print(f"Excel[{excel_filename}]: failed testify last_changed") + r = "parser.excel_found." + ("same" if same_date else "different") + "." + facultet + channel.basic_publish( + exchange=EXCHANGE_NAME, + routing_key=r, + properties=pika.BasicProperties( + content_type="application/json", + delivery_mode=2 + ), + body=json.dumps({ + "type": "excel_file_found", + "same": same_date, + "excel_dict": excel_dict + }, ensure_ascii=False).encode('utf-8') + ) + print(f"RabbitMQ published r={r}") + if same_date: state['actual_at'] = currt() try: @@ -191,6 +224,7 @@ def run_session(): save_parsed_state(excel_filename, state) continue + changed = True excel_dict['different_in_this_session'] = True state['actual_at'] = currt() state['excel'] = excel_dict @@ -203,6 +237,20 @@ def run_session(): state['sheets'] = parse_sheets(download_place) + channel.basic_publish( + exchange=EXCHANGE_NAME, + routing_key="parser.excel_parsed." + facultet, + properties=pika.BasicProperties( + content_type="application/json", + delivery_mode=2 + ), + body=json.dumps({ + "type": "excel_file_parsed", + "is_new": is_new, + "state": state + }, ensure_ascii=False).encode('utf-8') + ) + save_parsed_state(excel_filename, state) states.append(state) @@ -232,20 +280,35 @@ def run_session(): "all_files": EXCEL_LINKS, "faileds": faileds }, fp=fp, ensure_ascii=False) - - with open("result_v2.json", 'w', encoding="utf-8") as fp: + + if changed: all_files = states - json.dump({ - "version": 2, - "notice": "ОТКАЗ ОТ ОТВЕТСТВЕННОСТИ: ПРЕДОСТАВЛЯЕТСЯ КАК-ЕСТЬ (AS-IS) БЕЗ КАКИХ ЛИБО ГАРАНТИЙ", - "contact": "https://fazziclay.com/ или fazziclay@gmail.com", - "api_notices": { - "just_save_and_check_diffs": "просто сохраните и проверяйте разницу" - }, - "actual_at": currt(), - "all_files": sorted(all_files, key=lambda d: d['excel']['url']), - "faileds": faileds - }, fp=fp, ensure_ascii=False) + d = { + "version": 2, + "notice": "ОТКАЗ ОТ ОТВЕТСТВЕННОСТИ: ПРЕДОСТАВЛЯЕТСЯ КАК-ЕСТЬ (AS-IS) БЕЗ КАКИХ ЛИБО ГАРАНТИЙ", + "contact": "https://fazziclay.com/ или fazziclay@gmail.com", + "api_notices": { + "just_save_and_check_diffs": "просто сохраните и проверяйте разницу" + }, + "actual_at": currt(), + "all_files": sorted(all_files, key=lambda d: d['excel']['url']), + "faileds": faileds + } + with open("result_v2.json", 'w', encoding="utf-8") as fp: + json.dump(d, fp=fp, ensure_ascii=False) + + channel.basic_publish( + exchange=EXCHANGE_NAME, + routing_key="parser.result_v2", + properties=pika.BasicProperties( + content_type="application/json", + delivery_mode=2 + ), + body=json.dumps({ + "type": "schedule_result_v2", + "data": d + }, ensure_ascii=False).encode('utf-8') + ) # Delete a non-empty directory and its contents try: @@ -253,18 +316,24 @@ def run_session(): print(f"Directory '{DIRNAME}' and its contents deleted successfully.") except Exception as e: print(f"Error deleting directory '{DIRNAME}': {e}") + + return {"changed": changed} def check_dirs(): + if not os.path.exists(PARSED_DIR): os.mkdir(PARSED_DIR) -def main(): +def main(): while True: + t = utils.StepTimeCounter() + err = None + sess = None try: check_dirs() - print("BEGIN run_session();") - run_session() + print("BEGIN run_session();") + sess = run_session() print("END run_session();") if DEBUG_ONE_FAC: @@ -272,8 +341,24 @@ def main(): break except Exception as e: + err = e print("Exception in run_session();") traceback.print_exception(e) + + channel.basic_publish( + exchange=EXCHANGE_NAME, + routing_key="parser.session_end." + ('complete' if err is None else 'failed'), + properties=pika.BasicProperties( + content_type="application/json", + delivery_mode=2 + ), + body=json.dumps({ + "type": "session_end", + "err": err, + "duration": t.step(), + "session": sess + }, ensure_ascii=False).encode('utf-8') + ) print("Sleep for 30 minutes") time.sleep(60*30) From 2fe6953586548088100855f1d6fe711c25146e6f Mon Sep 17 00:00:00 2001 From: FazziCLAY Date: Sat, 28 Mar 2026 22:11:58 +0300 Subject: [PATCH 13/14] fixes --- .gitignore | 2 +- main.py | 2 +- requirements.txt | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 4648488..99513ea 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,5 @@ __pycache__ result*.json groups.json diffable_dates.txt -parsed +parsed/ parser.json \ No newline at end of file diff --git a/main.py b/main.py index 8ad46eb..d4341d0 100644 --- a/main.py +++ b/main.py @@ -354,7 +354,7 @@ def main(): ), body=json.dumps({ "type": "session_end", - "err": err, + "err": str(err) if err else None, "duration": t.step(), "session": sess }, ensure_ascii=False).encode('utf-8') diff --git a/requirements.txt b/requirements.txt index 2b34fa0..ac55965 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ openpyxl xlrd beautifulsoup4 -requests \ No newline at end of file +requests +pika +python-dotenv \ No newline at end of file From c84f6e3ba11f9bdc23dbb9f61b108d8fb9d19e4f Mon Sep 17 00:00:00 2001 From: FazziCLAY Date: Sat, 28 Mar 2026 22:16:28 +0300 Subject: [PATCH 14/14] gitignore --- .env | 1 - .gitignore | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) delete mode 100644 .env diff --git a/.env b/.env deleted file mode 100644 index 416200b..0000000 --- a/.env +++ /dev/null @@ -1 +0,0 @@ -RABBITMQ_URL=amqp://guest:guest@localhost/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 99513ea..26105a5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ result*.json groups.json diffable_dates.txt parsed/ -parser.json \ No newline at end of file +parser.json +.env \ No newline at end of file