refactor: big refactor

This commit is contained in:
2026-03-18 22:15:49 +03:00
parent 7e0e4a0b71
commit 1199ce1554
11 changed files with 264 additions and 555 deletions

2
.gitignore vendored
View File

@@ -5,3 +5,5 @@ __pycache__
result*.json result*.json
groups.json groups.json
diffable_dates.txt diffable_dates.txt
parsed
parser.json

View File

@@ -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"]

View File

@@ -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}")

View File

@@ -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}")

View File

@@ -51,15 +51,8 @@ def parse_links(facultets):
"url": url, "url": url,
"last_changed": last_changed "last_changed": last_changed
} }
print(record) print("Found in vstu.ru: ", record)
EXCEL_LINKS.append(record) EXCEL_LINKS.append(record)
return sorted(EXCEL_LINKS, key=lambda x: x['url']) return sorted(EXCEL_LINKS, key=lambda x: x['url'])
def excels_to_diffabledates(excels):
dates = []
for excel in excels:
dates.append(f"{excel['last_changed']} {excel['facultet']} {excel['url']}")
return "\n".join(sorted(dates)).strip()

274
main.py
View File

@@ -9,14 +9,12 @@ import random
import time import time
import traceback import traceback
import uuid import uuid
import aigenerated
import parser import parser
import translations import translations
import utils import utils
import json import json
import links_parser import links_parser
import shutil import shutil
import hashes
def currt(): def currt():
return round(time.time()) return round(time.time())
@@ -25,118 +23,39 @@ FACULTETS = sorted([
"asp", "mag", "fastiv", "fat", "ftkm", "ftpp", "feu", "fevt", "htf", "vkf", "mmf", "fpik" "asp", "mag", "fastiv", "fat", "ftkm", "ftpp", "feu", "fevt", "htf", "vkf", "mmf", "fpik"
]) ])
DIRNAME = "excels" DIRNAME = "excels"
DIFFABLE_DATES = "diffable_dates.txt" PARSED_DIR = "parsed"
SKIP_DIFFABLE_DATES = True
DEBUG_ONE_FAC = None #'fevt' DEBUG_ONE_FAC = None #'fevt'
LOGGING = False
unique_raws = set() parser.LOGGING = LOGGING = False
result = {
"version": 1,
"notice": "ОТКАЗ ОТ ОТВЕТСТВЕННОСТИ: Данные, доступ к API и т.д. предоставляется КАК-ЕСТЬ (AS-IS) без каких либо, явно или не явно подразумеваемых гарантий.\n\nПарсер написал: Миронов Станислав\n\nИсточник данных: https://www.vstu.ru/student/raspisaniya/zanyatiy/index.php",
"actual_at": round(time.time()),
"documentation": "https://fazziclay.com/api/v1/vstu_schedule_parser/scheme.json (temporary outdated)",
"daypicture": "0w0",
"daycite": "KIlLSWITCH",
"contact": "https://fazziclay.com/",
"university": "VSTU",
"university_site": "https://www.vstu.ru/",
"source": "https://fazziclay.com/api/v1/vstu_schedule_parser/result.json",
"stat": {
"total_parsing_time": -1,
"excels": {
"fine": 0,
"bad": 0
},
"groups": 0,
"unique_raws": -1
},
"api_notices": {
"updated_at": 1773523692,
"text_pre1": "Пожалуйста сохраняйте 'updated_at', это время изменения ЭТОГО текста. Тут возможно будут появлятся важные BREAKING CHANGES и дедлайны к ним.\nПо хорошему если updated_at другой по сравнению с вашем кэшем это сообщение должно отправляться вам в телеграм как уведомление о поедстоящих изменениях\nwarning=True значит 'text' содержит важное а не как щас hint.\n\n ~fazziclay aka Stanislav;\n\n2025-10-05: добавлено data_source_hash в эксель и в группу. Это SHA1 of скачанный эксель файл.",
"text": "2026-03-15 BREAKING CHANGES! By Stanislav Mironov.\n\nИзменено многое в угоду унифкации и расширению спаршенных групп. Пока alpha",
"warning": True,
"tut-plavayuschaya-struktura": "required only 'updated_at', 'text' and 'warning'"
},
"debug": {
"bleu~~": 3
},
"excels": [],
"facultets": FACULTETS,
"group_names_parsed": [],
"unique_raws": unique_raws,
"see_header_at_top_of_this_file": "SEE TOP OF THIS FILE | ОБРАТИТЕ ВНИМАНИЕ НА ВЕРХ ЭТОГО ФАЙЛА"
}
def process_obj(data): def parse_sheets(download_place):
to_return = {}
try: try:
if isinstance(data, dict):
for key, value in data.items():
if key == "raw":
unique_raws.update(value)
process_obj(value)
# Если это список, проходим по его элементам
elif isinstance(data, list):
for item in data:
process_obj(item)
except Exception as e:
print("Failed process_obj")
print(e)
def process_excel_file(facultet, excel_url, counter, latest_changed):
is_xlsx = excel_url.endswith(".xlsx")
download_place = f"{DIRNAME}/" + f"_[C{counter}]_" + facultet + ".xls" + ("x" if is_xlsx else "")
excel_filename = excel_url.split("/")[-1]
if "ФЭУ" not in excel_filename:
print("SKIPPED")
return
excel_info = {
"filename": excel_filename,
"data_source_hash": None,
"url": excel_url,
"latest_changed": latest_changed,
"download_place": download_place,
"group_names_parsed": [],
"facultet": facultet,
"counter": counter,
"sheets": []
}
parser.LOGGING = LOGGING
try:
aigenerated.download_file_from_url(excel_url, download_place)
sha1hash = hashes.calculate_sha1(download_place)
excel_info['data_source_hash'] = sha1hash
reader = translations.create_reader(download_place) reader = translations.create_reader(download_place)
print("Reader info") print("Reader info")
print(reader.info()) print(reader.info())
while True: while True:
t = utils.StepTimeCounter()
print(f"Parsing sheet №{reader.get_sheet_index()+1} (from 1)") print(f"Parsing sheet №{reader.get_sheet_index()+1} (from 1)")
sheet_dict = { sheet_dict = {
"index": reader.get_sheet_index(), "index": reader.get_sheet_index(),
"name": reader.get_sheet_name(), "name": reader.get_sheet_name(),
"reader_info": reader.info(), "reader_info": reader.info(),
"group_names_parsed": [],
"groups": {} "groups": {}
} }
excel_info['sheets'].append(sheet_dict) to_return["SHEET_"+str(reader.get_sheet_index())] = sheet_dict
prs = parser.Parser(reader) prs = parser.Parser(reader)
print("Parser created; parser.parse();") print("Parser created; parser.parse();")
prs.parse() prs.parse()
print("parsed done!") print("parsed done!")
sheet_dict['parse_time'] = round(t.step())
if len(prs.raw_no_schedule) > 0: if len(prs.raw_no_schedule) > 0:
sheet_dict["raw_no_schedule"] = prs.raw_no_schedule sheet_dict["other_raws"] = prs.raw_no_schedule
if len(prs.features) > 0: if len(prs.features) > 0:
sheet_dict["features"] = sorted(prs.features) sheet_dict["features"] = sorted(prs.features)
@@ -147,16 +66,11 @@ def process_excel_file(facultet, excel_url, counter, latest_changed):
if prs.parser_warnings is not None and len(prs.parser_warnings) > 0: if prs.parser_warnings is not None and len(prs.parser_warnings) > 0:
sheet_dict["parser_warnings"] = prs.parser_warnings sheet_dict["parser_warnings"] = prs.parser_warnings
for group_name in prs.groups.keys(): for group_name_key in prs.groups.keys():
gr = prs.groups[group_name] gr = prs.groups[group_name_key]
gr["excel_url"] = excel_url
sheet_dict["group_names_parsed"].append(group_name)
excel_info["group_names_parsed"].append(group_name)
result["group_names_parsed"].append(group_name)
result['stat']['groups'] += 1
sheet_dict['week_keys_metadata'] = prs.week_keys_metadata sheet_dict['week_keys_metadata'] = prs.week_keys_metadata
sheet_dict['groups'][group_name] = gr sheet_dict['groups'][group_name_key] = gr
process_obj(gr['slots'])
print(f"Populates {len(prs.groups)} groups: " + " ".join(prs.groups.keys())) print(f"Populates {len(prs.groups)} groups: " + " ".join(prs.groups.keys()))
@@ -168,31 +82,57 @@ def process_excel_file(facultet, excel_url, counter, latest_changed):
print("Next sheet!") print("Next sheet!")
except Exception as e: except Exception as e:
print(f"Error while {excel_url}")
print(e) print(e)
traceback.print_exc() traceback.print_exc()
u = uuid.uuid4() u = uuid.uuid4()
excel_info['error'] = { to_return['error'] = {
"smile": ":(", "smile": ":(",
"error_message": str(e), "error_message": str(e),
"log_anchor": str(u), "log_anchor": str(u),
"time": currt() "time": currt()
} }
print(f"Log Anchor: {u}") print(f"Log Anchor: {u}")
faileds.append({
"ex": e,
"fac": facultet,
"url": excel_url
})
result['excels'].append(excel_info) return to_return
k = "fine" if len(excel_info['group_names_parsed']) > 0 else "bad"
result['stat']['excels'][k] += 1 def parsed_file_path(excel_filename: str):
format = excel_filename.split(".")[-1]
fl = format.lower()
if fl not in ["json", "xls", "xlsx"]:
print(f"Unknown filename format: {excel_filename}")
return
if fl != "json":
excel_filename = excel_filename.replace("." + format, ".json")
excel_filename = excel_filename.lower()
filepath = PARSED_DIR + os.path.sep + excel_filename
return filepath
def load_parsed_state(excel_filename):
filepath = parsed_file_path(excel_filename)
if not os.path.exists(filepath):
return
with open(filepath, "r", encoding="utf-8") as fp:
return json.load(fp=fp)
def save_parsed_state(excel_filename, obj):
filepath = parsed_file_path(excel_filename)
with open(filepath, "w", encoding="utf-8") as fp:
json.dump(obj, fp=fp, ensure_ascii=False, sort_keys=True)
print(f"Saved parsed state to '{filepath}'")
def run_session():
faileds = []
faileds = []
def main():
global result
t = utils.StepTimeCounter() t = utils.StepTimeCounter()
# Delete tempdir
try: try:
try: try:
shutil.rmtree(DIRNAME) shutil.rmtree(DIRNAME)
@@ -205,43 +145,83 @@ def main():
print(f"Failed create '{DIRNAME}': ") print(f"Failed create '{DIRNAME}': ")
raise e raise e
print("main(); parse links starting...") print("main(); parse links starting...")
EXCEL_LINKS = links_parser.parse_links(FACULTETS if DEBUG_ONE_FAC is None else [DEBUG_ONE_FAC]) EXCEL_LINKS = links_parser.parse_links(FACULTETS if DEBUG_ONE_FAC is None else [DEBUG_ONE_FAC])
now_diffable_dates = links_parser.excels_to_diffabledates(EXCEL_LINKS)
prev_diffable_dates = None
if os.path.exists("diffable_dates.txt"):
with open(DIFFABLE_DATES, 'r') as fp:
prev_diffable_dates = fp.read().strip()
with open(DIFFABLE_DATES, 'w') as fp: last_changeds = set()
fp.write(now_diffable_dates) for excel_dict in EXCEL_LINKS:
try:
last_changeds.add(excel_dict['last_changed'])
if now_diffable_dates == prev_diffable_dates: excel_url = excel_dict['url']
print("No date changes in vstu.ru website. Stopping") facultet = excel_dict['facultet']
if not SKIP_DIFFABLE_DATES: excel_filename = excel_url.split("/")[-1]
return excel_dict['json_represent'] = parsed_file_path(excel_filename).split(os.path.sep)[-1]
print("SKIP_DIFFABLE_DATES is True, force resuming")
counter = 10000
for excel_link in EXCEL_LINKS:
counter += 1
facultet = excel_link['facultet']
excel_url = excel_link['url']
latest_changed = excel_link['last_changed']
process_excel_file(facultet, excel_url, counter, latest_changed)
print("Saving result.json") state = load_parsed_state(excel_filename)
result['stat']['total_parsing_time'] = t.step() is_new = state is None
result['unique_raws'] = sorted(unique_raws) if is_new:
state = {}
json.dump(result, open('result.json', 'w'), indent=2, ensure_ascii=False) else:
print("Saved to result.json indent=2") same_date = False
try:
same_date = state['excel']['last_changed'] == excel_dict['last_changed']
print(f"Excel[{excel_filename}]: inServer={excel_dict['last_changed']}, inState={state['excel']['last_changed']} same={same_date}")
json.dump(result, open('result-no-indent.json', 'w'), ensure_ascii=False) except Exception as e:
print("Saved to result-no-indent.json") print(f"Excel[{excel_filename}]: failed testify last_changed")
print("Faileds:") if same_date:
print(faileds) state['actual_at'] = currt()
try:
del state['excel']['different_in_this_session']
except: pass
save_parsed_state(excel_filename, state)
continue
excel_dict['different_in_this_session'] = True
state['actual_at'] = currt()
state['excel'] = excel_dict
is_xlsx = excel_url.endswith(".xlsx")
download_place = f"{DIRNAME}/" + excel_filename + "_" + facultet + ".xls" + ("x" if is_xlsx else "")
utils.download_file_from_url(excel_url, download_place)
sha1hash = utils.calculate_sha1(download_place)
state['excel']['sha1hash'] = sha1hash
state['sheets'] = parse_sheets(download_place)
save_parsed_state(excel_filename, state)
except Exception as e:
faileds.append({
"uuid": str(uuid.uuid4()),
"exception": str(e),
"traceback": traceback.format_exception(e),
"context": f"Failed process excel file {excel_dict['url']}"
})
traceback.print_exception(e)
with open("parser.json", 'w', encoding="utf-8") as fp:
lc = {"*_x": ":("}
try:
s = sorted(last_changeds)
lc = {
"early": s[0],
"newly": s[-1]
}
except: pass
json.dump({
"last_changeds": lc,
"actual_at": currt(),
"all_files": EXCEL_LINKS,
"faileds": faileds
}, fp=fp, ensure_ascii=False)
# Delete a non-empty directory and its contents # Delete a non-empty directory and its contents
try: try:
@@ -251,6 +231,22 @@ def main():
print(f"Error deleting directory '{DIRNAME}': {e}") print(f"Error deleting directory '{DIRNAME}': {e}")
def main():
while True:
try:
print("BEGIN run_session();")
run_session()
print("END run_session();")
except Exception as e:
print("Exception in run_session();")
traceback.print_exception(e)
print("Sleep for 30 minutes")
time.sleep(60*30)
print("Wake up!")
if __name__ == "__main__": if __name__ == "__main__":
print("Start") print("Start")
main() main()

View File

@@ -9,10 +9,11 @@ WEEKDAYS_STARTSWITH = [
"среда", "среда",
"четверг", "четверг",
"пятница", "пятница",
"суббота" "суббота",
"воскр"
] ]
bad_group_names = [ BAD_GROUP_NAMES = [
"янв", "февр", "март", "апр", "май", "сент", "окт", "ноя", "дек", "июнь", "июль", "авг" "янв", "февр", "март", "апр", "май", "сент", "окт", "ноя", "дек", "июнь", "июль", "авг"
] ]
@@ -68,6 +69,7 @@ class Parser:
print(" -- Failed parse! -- ") print(" -- Failed parse! -- ")
print("дни недели не найдены!") print("дни недели не найдены!")
self.parser_error = f"{WEEKDAYS_STARTSWITH} ни один найден в таблице. Дня недели нет." self.parser_error = f"{WEEKDAYS_STARTSWITH} ни один найден в таблице. Дня недели нет."
self.parse_raw_no_schedule()
return return
pair_num_any = self.reader.find_any(PAIR_NUMS, nospace=True) pair_num_any = self.reader.find_any(PAIR_NUMS, nospace=True)
@@ -98,6 +100,7 @@ class Parser:
print(head_joined) print(head_joined)
if "1 неделя" in head_joined or "1 НЕДЕЛЯ" in head_joined or "2 неделя" in head_joined or "2 НЕДЕЛЯ" in head_joined or "ИЗМЕНЕНИЯ" in head_joined or "изменения" in head_joined or "vtf-vstu.ru" in head_joined: if "1 неделя" in head_joined or "1 НЕДЕЛЯ" in head_joined or "2 неделя" in head_joined or "2 НЕДЕЛЯ" in head_joined or "ИЗМЕНЕНИЯ" in head_joined or "изменения" in head_joined or "vtf-vstu.ru" in head_joined:
head_rx -= 1 head_rx -= 1
self.raw_no_schedule.append(head_joined)
head = self.reader.get_row_values(head_rx) # get all ROW (months, groups) head = self.reader.get_row_values(head_rx) # get all ROW (months, groups)
pprint(f"head (upper)={head}") pprint(f"head (upper)={head}")
self.features.add("post_groups_info_row") self.features.add("post_groups_info_row")
@@ -133,7 +136,7 @@ class Parser:
def parse_raw_no_schedule(self): def parse_raw_no_schedule(self):
"""Распарсить всё за пределами self.schedule_range_row в self.raw_no_schedule""" """Распарсить всё за пределами self.schedule_range_row в self.raw_no_schedule"""
if self.schedule_range_row is None: if self.schedule_range_row is None:
return self.schedule_range_row = [999999999, 999999999] # прекрасное далёко
row = 0 row = 0
while row < self.reader.get_row_count(): while row < self.reader.get_row_count():
@@ -209,6 +212,7 @@ class Parser:
self.weeknums[week_key_name].add(merged) self.weeknums[week_key_name].add(merged)
def row_with_schedule_notify(self, row_coord): def row_with_schedule_notify(self, row_coord):
"""Вызывается каждый раз когда в переданной row обранужено расписание"""
if self.schedule_range_row is None: if self.schedule_range_row is None:
self.schedule_range_row = [row_coord, row_coord] self.schedule_range_row = [row_coord, row_coord]
@@ -218,20 +222,6 @@ class Parser:
if self.schedule_range_row[0] > row_coord: if self.schedule_range_row[0] > row_coord:
self.schedule_range_row[0] = row_coord self.schedule_range_row[0] = row_coord
def parse_potokoviy(self, merged: Merged):
speaker = None
location = None
# speaker
low = merged.low
speaker_pos = low.shift(down=merged.height())
speaker = speaker_pos.cell(self.reader).value
# location
location = merged.high.shift(down=1).cell(self.reader).value
return {"loc": str(location).strip(), "leader": str(speaker).strip(), "name": str(merged.cell(self.reader).value).strip()}
def process_group(self, group: dict, first_weekday: Coord, pair_pos_col): def process_group(self, group: dict, first_weekday: Coord, pair_pos_col):
""" """
Обработать группы, выполняется для каждой группы, после того как они распарены (parse_groups) Обработать группы, выполняется для каждой группы, после того как они распарены (parse_groups)
@@ -245,14 +235,12 @@ class Parser:
group_header_pos = Coord(group['position'][0], group['position'][1]) group_header_pos = Coord(group['position'][0], group['position'][1])
width = group['width'] width = group['width']
weeknum = 1 # номер недели, щёлкнет +1 при каком-то условии. weeknum = 1 # номер недели, щёлкнет +1 при каком-то условии.
previous_pair = None
weekcycles = 0 weekcycles = 0
while row_c1 < self.reader.get_row_count(): while row_c1 < self.reader.get_row_count():
pos_c1 = Coord(row_c1, group['position'][1]) # текущая позиция, верхний левый угол (=low) pos_c1 = Coord(row_c1, group['position'][1]) # текущая позиция, верхний левый угол (=low)
self.row_with_schedule_notify(pos_c1.row) self.row_with_schedule_notify(pos_c1.row)
if pos_c1.cell(self.reader).is_nospace_nocase_same(group_name): if pos_c1.cell(self.reader).is_nospace_nocase_same(group_name):
pprint("Ended with grpup name; stop moving down, break") pprint("Ended with grpup name; stop moving down, break")
break break
@@ -316,6 +304,7 @@ class Parser:
pair_num_to_add = pair_num_mr.cell(self.reader).value.replace(" ", "").strip() pair_num_to_add = pair_num_mr.cell(self.reader).value.replace(" ", "").strip()
if len(pair_num_to_add) == 0: if len(pair_num_to_add) == 0:
pair_num_to_add = "???" pair_num_to_add = "???"
pprint("Составители эксельки? Вы почему не указали номер пары ёклмн")
pairs.add(pair_num_to_add) pairs.add(pair_num_to_add)
for cell in dirty_line: for cell in dirty_line:
@@ -351,7 +340,6 @@ class Parser:
pair_name = sorted(pairs)[0] pair_name = sorted(pairs)[0]
except: pass except: pass
today = slots[w]
obj = { obj = {
"object": "event", "object": "event",
"pairs": sorted(pairs), "pairs": sorted(pairs),
@@ -364,10 +352,29 @@ class Parser:
if len(times) > 0: if len(times) > 0:
obj['times'] = times obj['times'] = times
if pair_pos_col is None: def smart_insert(first_dict, key, to_insert):
slots[w] = obj if key not in first_dict.keys():
first_dict[key] = {}
if isinstance(first_dict[key], dict):
if len(first_dict[key].keys()) == 0:
first_dict[key] = to_insert
else: else:
today[pair_name] = obj p = first_dict[key]
first_dict[key] = [p, to_insert]
elif isinstance(first_dict[key], list):
first_dict[key].append(to_insert)
else:
self.parser_warnings.append("Wtf? first_dict[key] not is dict and not is list??? (internal error)")
if pair_pos_col is None:
smart_insert(slots, w, obj)
else:
smart_insert(slots[w], pair_name, obj)
# here may be a empty all_raw # here may be a empty all_raw
clean_state() clean_state()
first_coord = None first_coord = None
@@ -397,13 +404,13 @@ def parse_groups(reader: "ExcelSheetReader", head, col_start, head_rx):
name = utils.unspace(x) name = utils.unspace(x)
skip = False skip = False
if "-" not in name: if "-" not in name:
for x in bad_group_names: for x in BAD_GROUP_NAMES:
if x in name.lower(): if x in name.lower():
skip = True skip = True
pprint(f"Skip groupname {name} because not dash in name and in blacklist") pprint(f"Skip groupname {name} because not dash in name and in blacklist")
if not skip: if not skip:
groups[name] = { groups[name.lower()] = {
"name": name, "name": name,
"position": [head_rx, i], "position": [head_rx, i],
"width": merged.width(), "width": merged.width(),

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
openpyxl
xlrd
beautifulsoup4
requests

View File

@@ -1,7 +1,7 @@
# --- Абстрактный базовый класс (Контракт) --- # Copyright Stanislav Mironov
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from datetime import datetime, time from datetime import time
import openpyxl import openpyxl
import xlrd import xlrd
@@ -27,6 +27,7 @@ class TranschendentnostCell:
def is_empty(self): def is_empty(self):
return self._is_empty return self._is_empty
# --- Абстрактный базовый класс (Контракт) ---
class ExcelSheetReader(ABC): class ExcelSheetReader(ABC):
""" """
Абстрактный базовый класс, определяющий интерфейс для чтения данных из листа Excel. Абстрактный базовый класс, определяющий интерфейс для чтения данных из листа Excel.
@@ -130,7 +131,6 @@ class ExcelSheetReader(ABC):
# --- Реализация №1: Обертка для xlrd --- # --- Реализация №1: Обертка для xlrd ---
class XlrdSheetReader(ExcelSheetReader): class XlrdSheetReader(ExcelSheetReader):
def __init__(self, file_path, sheet_index=0): def __init__(self, file_path, sheet_index=0):
super().__init__(file_path) super().__init__(file_path)
@@ -219,7 +219,6 @@ class XlrdSheetReader(ExcelSheetReader):
# --- Реализация №2: Обертка-транслятор для openpyxl --- # --- Реализация №2: Обертка-транслятор для openpyxl ---
class OpenpyxlSheetReader(ExcelSheetReader): class OpenpyxlSheetReader(ExcelSheetReader):
def __init__(self, file_path, sheet_name=None): def __init__(self, file_path, sheet_name=None):
super().__init__(file_path) super().__init__(file_path)
@@ -309,8 +308,7 @@ class OpenpyxlSheetReader(ExcelSheetReader):
return [] return []
# --- Фабричная функция (Ваша единственная точка входа) --- # --- Фабричная функция (единственная точка входа) ---
def create_reader(file_path, **kwargs) -> ExcelSheetReader: def create_reader(file_path, **kwargs) -> ExcelSheetReader:
""" """
Создает и возвращает подходящий экземпляр ридера в зависимости от расширения файла. Создает и возвращает подходящий экземпляр ридера в зависимости от расширения файла.

129
utils.py
View File

@@ -1,12 +1,82 @@
# Copyright Stanislav Mironov # Copyright Stanislav Mironov
import time import time
import xlrd import xlrd
from coord import Coord, Merged from coord import Coord
from translations import ExcelSheetReader from translations import ExcelSheetReader
import re
import hashlib
import requests
from urllib.parse import urlsplit, urlunsplit, quote
def download_file_from_url(url, output_filename):
"""
Скачивает файл по URL со спецсимволами и пробелами, сохраняя его под указанным именем.
Args:
url (str): Исходный URL, который может содержать пробелы и кириллицу.
output_filename (str): Имя файла для сохранения (например, 'calc.xls').
"""
try:
# --- Шаг 1: Правильное кодирование URL ---
# Разбираем URL на части: ('https', 'www.vstu.ru', '/path/to file.xls', '', '')
parts = urlsplit(url)
# Кодируем только путь, оставляя слэши '/' безопасными
# Это превратит ' ' в '%20', 'В' в '%D0%92' и т.д.
encoded_path = quote(parts.path, safe='/-_')
# Собираем URL обратно из частей с уже закодированным путем
encoded_url = urlunsplit((parts.scheme, parts.netloc, encoded_path, parts.query, parts.fragment))
# --- Шаг 2: Скачивание файла ---
response = requests.get(encoded_url, stream=True)
# Проверяем, успешен ли запрос (код 200 OK)
# Если сервер вернет ошибку (404, 500 и т.д.), здесь возникнет исключение
response.raise_for_status()
# --- Шаг 3: Сохранение файла ---
# Открываем файл для записи в бинарном режиме ('wb')
# Использование 'with' гарантирует, что файл будет закрыт автоматически
with open(output_filename, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
print(f"✅ Файл успешно скачан и сохранен как '{output_filename}'")
except requests.exceptions.RequestException as e:
print(f"❌ Ошибка скачивания: {e}")
except Exception as e:
print(f"❌ Произошла непредвиденная ошибка: {e}")
def calculate_sha1(filepath):
"""
Calculates the SHA1 hash of a given file.
Args:
filepath (str): The path to the file.
Returns:
str: The hexadecimal representation of the SHA1 hash, or None if the file is not found.
"""
sha1_hash = hashlib.sha1()
try:
with open(filepath, "rb") as f:
# Read the file in chunks to handle large files efficiently
for chunk in iter(lambda: f.read(4096), b""):
sha1_hash.update(chunk)
return sha1_hash.hexdigest()
except FileNotFoundError:
print(f"Error: File not found at {filepath}")
return None
except Exception as e:
print(f"An error occurred: {e}")
return None
class StepTimeCounter: class StepTimeCounter:
def __init__(self): def __init__(self):
@@ -69,58 +139,6 @@ def parse_all_dirt(reader: "ExcelSheetReader", min_pos: Coord, right, down, with
return RET return RET
# GEMINI GENERATED
def normalize_name(raw_name):
"""
Приводит разнородные записи ФИО к единому структурированному виду.
"""
# Шаг 1: Очистка
name = re.sub(r'\s+', ' ', raw_name).strip()
# Шаг 2: Извлечение звания
known_titles = ['проф.', 'доц.', 'акад.', 'к.т.н.', 'д.м.н.']
title = None
for t in known_titles:
if name.lower().startswith(t):
title = t
# Удаляем звание из строки, убираем лишние пробелы
name = name[len(t):].strip()
break
# Шаг 3 и 4: Разделение и идентификация
parts = name.split(' ')
last_name = None
initials = None
# Простой эвристический анализ
# Ищем инициалы (содержат точку или состоят из 1-2 заглавных букв)
initials_parts = []
name_parts = []
for part in parts:
if '.' in part or (1 <= len(part) <= 2 and part.isupper()):
initials_parts.append(part)
else:
# Считаем все остальное частью фамилии (для двойных фамилий)
name_parts.append(part)
if name_parts:
last_name = " ".join(name_parts)
if initials_parts:
initials = "".join(initials_parts) # Сливаем "А." и "Н." в "А.Н."
# Если фамилия не найдена (например, только инициалы),
# но есть части, считаем первую часть фамилией
if not last_name and name_parts:
last_name = name_parts[0]
return {
"last_name": last_name,
"initials": initials,
"title": title
}
def excel_coordinate(row, col): def excel_coordinate(row, col):
""" """
Преобразует координаты строки и столбца (начиная с 0) в эквивалент Excel (например, A7, CB34). Преобразует координаты строки и столбца (начиная с 0) в эквивалент Excel (например, A7, CB34).
@@ -179,5 +197,6 @@ def weekday_to_num(st: str):
if st.upper().strip().startswith("ВОСКР"): if st.upper().strip().startswith("ВОСКР"):
return 7 return 7
print(f"Unknown weekday num for str: {st}; returnted -1")
return -1 return -1

View File

@@ -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
}