refactor: big refactor
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,3 +5,5 @@ __pycache__
|
||||
result*.json
|
||||
groups.json
|
||||
diffable_dates.txt
|
||||
parsed
|
||||
parser.json
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
CMD ["python", "-u", "main.py"]
|
||||
201
aigenerated.py
201
aigenerated.py
@@ -1,201 +0,0 @@
|
||||
# Copyright GEMINI
|
||||
|
||||
|
||||
import re
|
||||
|
||||
# --- Ресурсы для алгоритма ---
|
||||
STOP_WORDS = {'пр.', 'лек.', 'лаб.', 'семинар'}
|
||||
TITLES = ('доц.', 'проф.', 'асс.', 'ст.пр.')
|
||||
SURNAME_ENDINGS = ('ов', 'ев', 'ин', 'ский', 'цкой', 'их', 'ых', 'ова', 'ева', 'ина', 'ская', 'ян', 'ко', "ня", "ин")
|
||||
|
||||
def is_surname_string(s: str) -> bool:
|
||||
"""
|
||||
Классифицирует строку, определяя, содержит ли она фамилию.
|
||||
"""
|
||||
if not isinstance(s, str) or not s:
|
||||
return False
|
||||
|
||||
# Шаг 1: Очистка
|
||||
s_clean = s.strip()
|
||||
|
||||
# Шаг 2: Жесткие правила "НЕ ФАМИЛИЯ"
|
||||
if s_clean.lower() in STOP_WORDS:
|
||||
return False
|
||||
if s_clean.isupper() and len(s_clean) > 3:
|
||||
return False
|
||||
if re.search(r'[\(\)/«»%]', s_clean):
|
||||
return False
|
||||
|
||||
words = s_clean.split()
|
||||
if len(words) > 3:
|
||||
return False
|
||||
|
||||
# Шаг 3: Жесткие правила "ТОЧНО ФАМИЛИЯ"
|
||||
if re.search(r'\b[А-Я]\.?', s_clean): # Ищет "И.А." или "И.А"
|
||||
return True
|
||||
if s_clean.lower().startswith(TITLES):
|
||||
return True
|
||||
|
||||
# Шаг 4: Эвристический анализ для оставшихся случаев
|
||||
score = 0
|
||||
|
||||
# Правило на капитализацию
|
||||
if words and words[0][0].isupper():
|
||||
score += 5
|
||||
else:
|
||||
# Если слово не с большой буквы, это почти точно не фамилия
|
||||
return False
|
||||
|
||||
# Правило на окончания
|
||||
last_word = words[-1].lower()
|
||||
if last_word.endswith(SURNAME_ENDINGS):
|
||||
score += 6
|
||||
|
||||
# Правило на количество слов
|
||||
if len(words) in [1, 2]:
|
||||
score += 2
|
||||
|
||||
# Пороговое значение
|
||||
THRESHOLD = 8
|
||||
return score >= THRESHOLD
|
||||
|
||||
|
||||
|
||||
def extract_last_name(name_str: str) -> str or None:
|
||||
"""
|
||||
Извлекает из строки только фамилию.
|
||||
|
||||
Справляется с приклеенными званиями (типа "ст.пр.Дмитриев") и отбрасывает инициалы.
|
||||
Ищет первое слово, написанное с заглавной буквы.
|
||||
|
||||
Args:
|
||||
name_str: Исходная "грязная" строка с именем.
|
||||
|
||||
Returns:
|
||||
Чистая фамилия в виде строки, или None, если фамилия не найдена.
|
||||
"""
|
||||
# Проверка, что на вход подана строка
|
||||
if not isinstance(name_str, str):
|
||||
return None
|
||||
|
||||
# Паттерн для поиска:
|
||||
# [А-ЯЁ] - одна заглавная русская буква в начале
|
||||
# [а-яё]+ - одна или более строчных русских букв после неё
|
||||
# (?:-[А-ЯЁ][а-яё]+)? - опциональная часть для двойных фамилий (например, -Петров)
|
||||
pattern = r'[А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)?'
|
||||
|
||||
match = re.search(pattern, name_str)
|
||||
|
||||
if match:
|
||||
return match.group(0) # Возвращаем найденное совпадение
|
||||
else:
|
||||
return None # Если ничего не найдено
|
||||
|
||||
|
||||
# --- Шаг 0: Константы ---
|
||||
POSITIVE_KEYWORDS = ['зал', 'ауд', 'каб', 'корп', 'кор.', "ЛК"]
|
||||
NEGATIVE_KEYWORDS = ['доц', 'проф', 'асс', 'лек', 'пр']
|
||||
|
||||
# Транслитерация для унификации
|
||||
TRANS_TABLE = str.maketrans('TCBAHIMK', 'ТКВАНІМК') # Латиница -> Кириллица
|
||||
|
||||
def is_room_number(s: str) -> bool:
|
||||
"""
|
||||
Проверяет, является ли строка номером кабинета, по многоуровневому алгоритму.
|
||||
"""
|
||||
# --- Шаг 1: Быстрые негативные фильтры ---
|
||||
if not isinstance(s, str) or not s.strip():
|
||||
return False # 1. Проверка на пустоту
|
||||
|
||||
if ',' in s:
|
||||
return False # 2. Проверка на запятые (даты)
|
||||
|
||||
# 3. Проверка на "очевидный мусор"
|
||||
first_word = s.strip().lower().split()[0]
|
||||
if first_word in NEGATIVE_KEYWORDS:
|
||||
return False
|
||||
|
||||
# 4. Проверка на инициалы (напр. А.Е.)
|
||||
if re.search(r'\b[А-Я]\.[А-Я]\.?\b', s):
|
||||
return False
|
||||
|
||||
# 5. Проверка на длинное слово без цифр
|
||||
if not any(char.isdigit() for char in s) and len(s.split()) == 1 and len(s) > 10:
|
||||
return False
|
||||
|
||||
# --- Шаг 2: Быстрые позитивные фильтры ---
|
||||
s_lower = s.lower()
|
||||
if any(keyword in s_lower for keyword in POSITIVE_KEYWORDS):
|
||||
return True # 1. Проверка по ключевым словам
|
||||
|
||||
# --- Шаг 3: Основной анализ ---
|
||||
if not any(char.isdigit() for char in s):
|
||||
return False # 1. Требуется наличие цифр
|
||||
|
||||
# 2. Создание "чистой" версии
|
||||
clean_s = s.upper().translate(TRANS_TABLE).replace(' ', '')
|
||||
|
||||
# 3. Комплексный паттерн для проверки
|
||||
# Пояснение:
|
||||
# ^...$ - шаблон должен соответствовать всей строке
|
||||
# [А-ЯЁ]?-? - необязательная буква и необязательный дефис в начале
|
||||
# \d+ - одна или более цифр (ядро номера)
|
||||
# (?:[.-]\d+)* - необязательные группы ".число" или "-число"
|
||||
# [А-ЯЁ]?$ - необязательная буква в конце
|
||||
room_pattern = re.compile(r'^[А-ЯЁ]?-?\d+(?:[.-]\d+)*[А-ЯЁ]?$')
|
||||
|
||||
if room_pattern.fullmatch(clean_s):
|
||||
return True
|
||||
|
||||
# --- Шаг 4: Финальное решение ---
|
||||
return False
|
||||
|
||||
|
||||
import requests
|
||||
from urllib.parse import urlsplit, urlunsplit, quote
|
||||
|
||||
def download_file_from_url(url, output_filename):
|
||||
"""
|
||||
Скачивает файл по URL со спецсимволами и пробелами, сохраняя его под указанным именем.
|
||||
|
||||
Args:
|
||||
url (str): Исходный URL, который может содержать пробелы и кириллицу.
|
||||
output_filename (str): Имя файла для сохранения (например, 'calc.xls').
|
||||
"""
|
||||
try:
|
||||
# --- Шаг 1: Правильное кодирование URL ---
|
||||
# Разбираем URL на части: ('https', 'www.vstu.ru', '/path/to file.xls', '', '')
|
||||
parts = urlsplit(url)
|
||||
|
||||
# Кодируем только путь, оставляя слэши '/' безопасными
|
||||
# Это превратит ' ' в '%20', 'В' в '%D0%92' и т.д.
|
||||
encoded_path = quote(parts.path, safe='/-_')
|
||||
|
||||
# Собираем URL обратно из частей с уже закодированным путем
|
||||
encoded_url = urlunsplit((parts.scheme, parts.netloc, encoded_path, parts.query, parts.fragment))
|
||||
|
||||
|
||||
# --- Шаг 2: Скачивание файла ---
|
||||
response = requests.get(encoded_url, stream=True)
|
||||
|
||||
# Проверяем, успешен ли запрос (код 200 OK)
|
||||
# Если сервер вернет ошибку (404, 500 и т.д.), здесь возникнет исключение
|
||||
response.raise_for_status()
|
||||
|
||||
# --- Шаг 3: Сохранение файла ---
|
||||
# Открываем файл для записи в бинарном режиме ('wb')
|
||||
# Использование 'with' гарантирует, что файл будет закрыт автоматически
|
||||
with open(output_filename, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
print(f"✅ Файл успешно скачан и сохранен как '{output_filename}'")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ Ошибка скачивания: {e}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Произошла непредвиденная ошибка: {e}")
|
||||
|
||||
|
||||
|
||||
34
hashes.py
34
hashes.py
@@ -1,34 +0,0 @@
|
||||
import hashlib
|
||||
|
||||
def calculate_sha1(filepath):
|
||||
"""
|
||||
Calculates the SHA1 hash of a given file.
|
||||
|
||||
Args:
|
||||
filepath (str): The path to the file.
|
||||
|
||||
Returns:
|
||||
str: The hexadecimal representation of the SHA1 hash, or None if the file is not found.
|
||||
"""
|
||||
sha1_hash = hashlib.sha1()
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
# Read the file in chunks to handle large files efficiently
|
||||
for chunk in iter(lambda: f.read(4096), b""):
|
||||
sha1_hash.update(chunk)
|
||||
return sha1_hash.hexdigest()
|
||||
except FileNotFoundError:
|
||||
print(f"Error: File not found at {filepath}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"An error occurred: {e}")
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage:
|
||||
file_path = "xls.xls" # Replace with the actual path to your file
|
||||
sha1_result = calculate_sha1(file_path)
|
||||
|
||||
if sha1_result:
|
||||
print(f"The SHA1 hash of '{file_path}' is: {sha1_result}")
|
||||
@@ -51,15 +51,8 @@ def parse_links(facultets):
|
||||
"url": url,
|
||||
"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()
|
||||
|
||||
274
main.py
274
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,118 +23,39 @@ 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
|
||||
|
||||
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()
|
||||
|
||||
# Delete tempdir
|
||||
try:
|
||||
try:
|
||||
shutil.rmtree(DIRNAME)
|
||||
@@ -205,43 +145,83 @@ 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")
|
||||
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]
|
||||
|
||||
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")
|
||||
result['stat']['total_parsing_time'] = t.step()
|
||||
result['unique_raws'] = sorted(unique_raws)
|
||||
state = load_parsed_state(excel_filename)
|
||||
is_new = state is None
|
||||
if is_new:
|
||||
state = {}
|
||||
|
||||
json.dump(result, open('result.json', 'w'), indent=2, ensure_ascii=False)
|
||||
print("Saved to result.json indent=2")
|
||||
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}")
|
||||
|
||||
json.dump(result, open('result-no-indent.json', 'w'), ensure_ascii=False)
|
||||
print("Saved to result-no-indent.json")
|
||||
except Exception as e:
|
||||
print(f"Excel[{excel_filename}]: failed testify last_changed")
|
||||
|
||||
print("Faileds:")
|
||||
print(faileds)
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
try:
|
||||
@@ -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()
|
||||
|
||||
57
parser.py
57
parser.py
@@ -9,10 +9,11 @@ WEEKDAYS_STARTSWITH = [
|
||||
"среда",
|
||||
"четверг",
|
||||
"пятница",
|
||||
"суббота"
|
||||
"суббота",
|
||||
"воскр"
|
||||
]
|
||||
|
||||
bad_group_names = [
|
||||
BAD_GROUP_NAMES = [
|
||||
"янв", "февр", "март", "апр", "май", "сент", "окт", "ноя", "дек", "июнь", "июль", "авг"
|
||||
]
|
||||
|
||||
@@ -68,6 +69,7 @@ class Parser:
|
||||
print(" -- Failed parse! -- ")
|
||||
print("дни недели не найдены!")
|
||||
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,14 +235,12 @@ 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")
|
||||
break
|
||||
@@ -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
|
||||
|
||||
if pair_pos_col is None:
|
||||
slots[w] = obj
|
||||
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:
|
||||
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
|
||||
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(),
|
||||
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
openpyxl
|
||||
xlrd
|
||||
beautifulsoup4
|
||||
requests
|
||||
@@ -1,7 +1,7 @@
|
||||
# --- Абстрактный базовый класс (Контракт) ---
|
||||
# Copyright Stanislav Mironov
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from 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:
|
||||
"""
|
||||
Создает и возвращает подходящий экземпляр ридера в зависимости от расширения файла.
|
||||
|
||||
129
utils.py
129
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
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
{
|
||||
"actual_at": "0", // unix seconds когда было распарсено это расписание
|
||||
"notice": "LEGAL INFORMATION HERE", // legal information
|
||||
"stat": {
|
||||
"total_parsing_time": 100.11 // сколько занял процесс парсинга от запуска программы (сек)
|
||||
},
|
||||
"version": "1", // версия = 1
|
||||
"excels": [ // список всех эксель файлов которые были обработаны.
|
||||
{
|
||||
"filename": "эксель как на сайте.xls",
|
||||
"url": "https://internet./path/to/эксель как на сайте.xls", // откуда парсер скачал
|
||||
"download_place": "excels/temp.xls", // куда парсер скачал
|
||||
"group_names_parsed": [""], // список названий групп которые удалось достать с этого файла
|
||||
"facultet": "mag", // english name of facultet к которому относится этот эксель документ
|
||||
"stat": {
|
||||
"download": 0.314, // сколько скачивалось (сек)
|
||||
"create_reader": 0.03, // сколько создавался Reader (сек), обычно у .xlsx это от нескольких секунд до минуты, а у .xls десятые доли секунды.
|
||||
"parse": 0.00, // сколько парсился (сек)
|
||||
"cycles": 2 // сколько парсер сделал циклов: по факту - сколько страниц в эксель файле
|
||||
},
|
||||
|
||||
// дальше optional
|
||||
"parser_error_cycle_1": "string desk from Parser", // _1 номер цикла. не больше stat.cycles
|
||||
"error": {"...":"..."} // исключение в коде
|
||||
}
|
||||
],
|
||||
|
||||
"groups": { // список групп с данными о них
|
||||
"имя-группы": {
|
||||
"name": "имя-группы",
|
||||
"position_human": "G6:J6", // range ячейки-заголовка группы в эксель таблице.
|
||||
"slots": { // слоты - расписание этой группы на 2 недели
|
||||
|
||||
// важно если у группы нету например занятий в первый понидельник то и ключа не будет. => если у группы нету занятий вообще то slots = {} (пустому словарю)
|
||||
|
||||
// день определённой недели
|
||||
// ключ - имя дня недели + (_1 OR _2 в зависимости от недели). Не полагайтесь на этот ключ, т.к. в каждой паре есть weekday (1-7) и weeknum (1-2).
|
||||
// значение - список пар на этот день
|
||||
"ПОНЕДЕЛЬНИК_1": {
|
||||
"1-2": { // ключ - название времени пары (1-2, 2-3, 3-4 etc...), значение - информация о паре (о дисциплине в это время)
|
||||
"excel_pos": "G7", // позиция в экселе
|
||||
"discipline_name": "Великое знание российское", // название дисциплины (не всегда корректное)
|
||||
"locations": [ // список локаций (не всегда корректный)
|
||||
"В 1204"
|
||||
],
|
||||
"leads": [ // список лидеров (спикеров, вообщем преподователей) (не всегда корректный)
|
||||
"Бадикова П.В."
|
||||
],
|
||||
"is_solid": true, // единая ли верхняя ячейка (т.е. ширина 4 колонки и она объединена в одну)
|
||||
"time_coeff": 2, // по сути сколько пар длится пара. Тоесть если >1 то длится больше одной пары. Почти всегда 1, иногда 2. В редких случаях (химия) 3 :dead: соболезную трудящимся
|
||||
"is_flow": false, // потоковая? определяется если ширина объединённой ячейки больше 4 (а 4 это размер колонки группы).
|
||||
"lefttopmerged": { // частично отладочная информация, как раз о объединённой ячейке взятой как тык пальцем в верхнюю левую ячейку.
|
||||
"width": 4,
|
||||
"height": 2,
|
||||
"excel_range": "G7:J8" // её range в экселе
|
||||
},
|
||||
"raw": [ // сырые данные. Содержание всех не пустых ячеек этой записи о паре. (SET data structure)
|
||||
"Великое знание российское",
|
||||
"В 1204б,лунатутможетбытьчтоугодно",
|
||||
"даты и т.д. всё что там есть будет тут",
|
||||
"практ. 4 час.",
|
||||
"Преподов П.П."
|
||||
],
|
||||
"weekday": 1, // день недели 1- понидельник, 7 - воскр..
|
||||
"weeknum": 1, // номер недели 1- первая 2-вторая.
|
||||
|
||||
// дальше optional
|
||||
"pair_num_empty": {"..": "...", "restored": true}, // составители расписания лучшие и в макете забыли сбоку добавить [1-2] номер пары. Если restored=true то это недоразумение удалось исправить опираясь на предыдущую пару.
|
||||
"to_many_parsing_time_coeff": true // если при попытке определить time_coeff, а он определяется по границам (borders) в эксель таблице, произошла ошибка (подавленный infinity loop)
|
||||
|
||||
}
|
||||
}
|
||||
},
|
||||
"facultet": "mag", // факультет группы
|
||||
"data_source": "эксельтаблица.xls" // название таблицы откуда были получены данные о этой группе
|
||||
}
|
||||
}
|
||||
|
||||
// безусловно перечислено не всё, но многое излишне.
|
||||
// ~ Stanislav Mironov
|
||||
}
|
||||
Reference in New Issue
Block a user