added openpyxl support

This commit is contained in:
2025-09-11 15:42:41 +03:00
parent 414907a929
commit babf491c8e
6 changed files with 224470 additions and 41307 deletions

View File

@@ -1,9 +1,5 @@
import xlrd
class Coord: class Coord:
def __init__(self, row, col): def __init__(self, row, col):
self.row = row self.row = row
@@ -17,8 +13,8 @@ class Coord:
return Coord(self.row if row is None else row, return Coord(self.row if row is None else row,
self.col if col is None else col) self.col if col is None else col)
def cell(self, sh) -> "xlrd.sheet.Cell": def cell(self, reader: "ExcelSheetReader") -> "TranschendentnostCell":
return sh.cell(self.row, self.col) return reader.cell(self.row, self.col)
def __repr__(self): def __repr__(self):
import utils import utils
@@ -49,8 +45,9 @@ class Merged:
def width(self): def width(self):
return self.high.col - self.low.col + 1 return self.high.col - self.low.col + 1
def cell(self, sh) -> "xlrd.sheet.Cell": def cell(self, reader: "ExcelSheetReader") -> "TranschendentnostCell":
return sh.cell(self.low.row, self.low.col) return reader.cell(self.low.row, self.low.col)
def is_pseudo_merged(self): def is_pseudo_merged(self):
"""Псевдо-мержнутая значит размеом 1x1, оно же если начало совпадает с концом""" """Псевдо-мержнутая значит размеом 1x1, оно же если начало совпадает с концом"""

40
main.py
View File

@@ -13,6 +13,7 @@ from requests.structures import CaseInsensitiveDict
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import aigenerated import aigenerated
import parser import parser
import translations
import utils import utils
import json import json
# Общее правило проекта, сначала в координатах идёт ROW а потом COL, нумерация с нуля # Общее правило проекта, сначала в координатах идёт ROW а потом COL, нумерация с нуля
@@ -76,41 +77,16 @@ for facultet in FACULTETS:
counter += 1 counter += 1
print(f"\n\n-- Ссылка --") print(f"\n\n-- Ссылка --")
print(f"{excel_url}") print(f"{excel_url}")
xlsx = excel_url.endswith(".xlsx") is_xlsx = excel_url.endswith(".xlsx")
try: try:
filename = "excels/" + facultet + filestime + f"[C{counter}]" + ".xls" filename = "excels/" + facultet + filestime + f"[C{counter}]" + ".xls" + ("x" if is_xlsx else "")
# Download a file
if not xlsx:
aigenerated.download_file_from_url(excel_url, filename) aigenerated.download_file_from_url(excel_url, filename)
else:
aigenerated.download_file_from_url(excel_url, filename+"x")
excel_file = pd.ExcelFile(filename + "x") reader = translations.create_reader(filename)
# Создаем "писателя" для формата .xls с помощью движка xlwt
# Использование 'with' гарантирует, что файл будет корректно сохранен и закрыт
with pd.ExcelWriter(filename, engine='xlwt') as writer:
print("Начинаю конвертацию...")
# Проходим по каждому листу в исходном файле
for sheet_name in excel_file.sheet_names:
print(f" - Обрабатываю лист: {sheet_name}")
# Читаем лист в DataFrame
df = excel_file.parse(sheet_name)
# Записываем этот DataFrame в новый .xls файл с тем же именем листа
# index=False чтобы не добавлять лишнюю колонку с индексами pandas
df.to_excel(writer, sheet_name=sheet_name, index=False)
print(f"✅ Успешно! Файл конвертирован как: {filename}")
book = xlrd.open_workbook(filename, formatting_info=True)
print("The number of worksheets is {0}".format(book.nsheets))
print("Worksheet name(s): {0}".format(book.sheet_names()))
sh = book.sheet_by_index(0)
parser.LOGGING = False parser.LOGGING = False
prs = parser.Parser(sh) prs = parser.Parser(reader)
prs.parse() prs.parse()
for group_name in prs.groups.keys(): for group_name in prs.groups.keys():
if group_name in result.keys(): if group_name in result.keys():
@@ -119,15 +95,17 @@ for facultet in FACULTETS:
gr = result[group_name] = prs.groups[group_name] gr = result[group_name] = prs.groups[group_name]
gr['facultet'] = facultet gr['facultet'] = facultet
gr['data_source'] = excel_url.split("/")[-1] + " SHEET: " + str(sh.name) gr['data_source'] = excel_url.split("/")[-1]
gr['parser_debug'] = { gr['parser_debug'] = {
"C_COUNTER": counter, "C_COUNTER": counter,
"timestime": filestime, "timestime": filestime,
"excel_url": excel_url, "excel_url": excel_url,
"sheet": sh.name, "reader_info": reader.info(),
"filename": filename "filename": filename
} }
print(f"Populates {len(prs.groups)} groups to result")
except Exception as e: except Exception as e:
print(f"Error while {excel_url}") print(f"Error while {excel_url}")
print(e) print(e)

View File

@@ -4,6 +4,7 @@ import xlrd
import aigenerated import aigenerated
from coord import Coord, Merged from coord import Coord, Merged
from translations import ExcelSheetReader
import utils import utils
LOGGING = True LOGGING = True
@@ -14,27 +15,27 @@ def pprint(*args, **kwargs):
class Parser: class Parser:
def __init__(self, sheet: "xlrd.sheet.Sheet"): def __init__(self, reader: ExcelSheetReader):
self.sh: "xlrd.sheet.Sheet" = sheet self.reader = reader
self.groups = {} self.groups = {}
self.teachers = set() self.teachers = set()
self.places = set() self.places = set()
pprint("Parser created for '{0}': size: {1}x{2}".format(self.sh.name, self.sh.nrows, self.sh.ncols)) pprint("Parser created for '{0}'".format(reader.info()))
def parse(self): def parse(self):
monday = utils.find(self.sh, "ПОНЕДЕЛЬНИК") monday = self.reader.find("ПОНЕДЕЛЬНИК")
if monday is None: if monday is None:
print(" -- Failed parse! -- ") print(" -- Failed parse! -- ")
print("ПОНЕДЕЛЬНИК НЕ НАЙДЕН!") print("ПОНЕДЕЛЬНИК НЕ НАЙДЕН!")
return return
head_rx = monday[0] - 1 # выше первого понидельника head_rx = monday.row - 1 # выше первого понидельника
if head_rx < 0: if head_rx < 0:
raise Exception("head_rx < 0: Программа пыталась найти 'ПОНЕДЕЛЬНИК', но по всей видимости не нашла.") raise Exception("head_rx < 0: Программа пыталась найти 'ПОНЕДЕЛЬНИК', но по всей видимости не нашла.")
head = self.sh.row(head_rx) # get all ROW (months, groups) head = self.reader.get_row_values(head_rx) # get all ROW (months, groups)
pprint(f"head={head}") pprint(f"head={head}")
self.groups = parse_groups(self.sh, head, monday, head_rx) # parse groups to self.groups self.groups = parse_groups(self.reader, head, monday, head_rx) # parse groups to self.groups
pprint(f'self.groups={json.dumps(self.groups, indent=2, ensure_ascii=False)}') pprint(f'self.groups={json.dumps(self.groups, indent=2, ensure_ascii=False)}')
pprint("\n\n\n") pprint("\n\n\n")
@@ -53,12 +54,12 @@ class Parser:
# speaker # speaker
low = merged.low low = merged.low
speaker_pos = low.shift(down=merged.height()) speaker_pos = low.shift(down=merged.height())
speaker = speaker_pos.cell(self.sh).value speaker = speaker_pos.cell(self.reader).value
# location # location
location = merged.high.shift(down=1).cell(self.sh).value location = merged.high.shift(down=1).cell(self.reader).value
return {"loc": str(location), "leader": str(speaker), "name": str(merged.cell(self.sh).value)} return {"loc": str(location), "leader": str(speaker), "name": str(merged.cell(self.reader).value)}
def process_group(self, group, monday): def process_group(self, group, monday):
""" """
@@ -70,24 +71,24 @@ class Parser:
pprint(group_name) pprint(group_name)
row = group['position'][0] + 1 # counter for while, +1 for shift down; также номер строки в таблице (вроде с нуля) row = group['position'][0] + 1 # counter for while, +1 for shift down; также номер строки в таблице (вроде с нуля)
weeknum = 1 # номер недели, щёлкнет +1 при каком-то условии. weeknum = 1 # номер недели, щёлкнет +1 при каком-то условии.
while row < self.sh.nrows: # maybe условие чтобы не уйти ниже чем есть строк while row < self.reader.get_row_count(): # maybe условие чтобы не уйти ниже чем есть строк
pos = Coord(row, group['position'][1]) # текущая позиция, верхний правый угол (=low) pos = Coord(row, group['position'][1]) # текущая позиция, верхний левый угол (=low)
pos_right = pos.shift(right=3) pos_right = pos.shift(right=3)
pair_pos = pos.replace(col=5) pair_pos = pos.replace(col=5)
weekday_pos = pos.replace(col=4) weekday_pos = pos.replace(col=4)
merged = utils.get_merged_coord(self.sh, pos) merged = self.reader.get_merged_coord(pos)
right_cell = pos_right.cell(self.sh) right_cell = pos_right.cell(self.reader)
merged_cell = merged.cell(self.sh) merged_cell = merged.cell(self.reader)
cv = merged_cell.value cv = merged_cell.value
# В конце (12 пара:>) название группы, можно использовать как якорь # В конце (12 пара:>) название группы, можно использовать как якорь
if utils.unspace(cv) == group_name: if utils.unspace(cv) == group_name:
pprint("Lesson == group name; ending group loop.") pprint("Lesson == group name; ending group loop.")
break break
weekday_mr = utils.get_merged_coord(self.sh, weekday_pos) weekday_mr = self.reader.get_merged_coord(weekday_pos)
weekday = utils.unspace(weekday_mr.cell(self.sh).value) weekday = utils.unspace(weekday_mr.cell(self.reader).value)
pair_mr = utils.get_merged_coord(self.sh, pair_pos) pair_mr = self.reader.get_merged_coord(pair_pos)
pair = utils.unspace(pair_mr.cell(self.sh).value) pair = utils.unspace(pair_mr.cell(self.reader).value)
skip = 0 skip = 0
if weekday == "": if weekday == "":
@@ -101,7 +102,7 @@ class Parser:
if not skip: if not skip:
next = 3 # на сколько пыгнуть для следующего шага? next = 3 # на сколько пыгнуть для следующего шага?
is_empty_lesson = right_cell.ctype in utils.EMPTY_CTYPES and merged_cell.ctype in utils.EMPTY_CTYPES is_empty_lesson = right_cell.is_empty() and merged_cell.is_empty()
dispname = "" dispname = ""
parsed_discipline_name = None parsed_discipline_name = None
parsed_location = None parsed_location = None
@@ -115,7 +116,7 @@ class Parser:
if not is_empty_lesson: if not is_empty_lesson:
may_prepod = merged.low.shift(down=2) may_prepod = merged.low.shift(down=2)
if utils.has_no_bottom_border(self.sh, may_prepod): if utils.has_no_bottom_border(self.reader, may_prepod):
next = 6 next = 6
is_2pair = True is_2pair = True
@@ -124,7 +125,7 @@ class Parser:
parsed_location = ret['loc'] parsed_location = ret['loc']
parsed_leader = ret['leader'] parsed_leader = ret['leader']
parsed_discipline_name = ret['name'] parsed_discipline_name = ret['name']
parsed_uncotigorized = list(utils.parse_all_dirt(self.sh, merged.low, merged.width(), next)) parsed_uncotigorized = list(utils.parse_all_dirt(self.reader, merged.low, merged.width(), next))
else: else:
@@ -135,7 +136,7 @@ class Parser:
dispname += (" SOLD" if is_solid else " SPLIT") dispname += (" SOLD" if is_solid else " SPLIT")
dispname += (" [ДВУПАРНЫЙ]" if is_2pair else "") dispname += (" [ДВУПАРНЫЙ]" if is_2pair else "")
parsed_uncotigorized = list(utils.parse_all_dirt(self.sh, merged.low, 4, next)) parsed_uncotigorized = list(utils.parse_all_dirt(self.reader, merged.low, 4, next))
if parsed_leader: dispname += f" [{parsed_leader}]" if parsed_leader: dispname += f" [{parsed_leader}]"
@@ -194,23 +195,23 @@ class Parser:
def parse_groups(sh, head, monday, head_rx): def parse_groups(reader: "ExcelSheetReader", head, monday: Coord, head_rx):
"""Распознать список групп и метаданные к ним, по сути получить список названий группы и координат её верхнего header-а (AQ6:AT6)""" """Распознать список групп и метаданные к ним, по сути получить список названий группы и координат её верхнего header-а (AQ6:AT6)"""
groups = {} groups = {}
i = 0 i = 0
while i < len(head): while i < len(head):
x = head[i] x = head[i]
pprint(f"while i={i} head[i]={x}") pprint(f"while i={i} head[i]={x}")
merged = utils.get_merged_coord(sh, Coord(head_rx, i)) merged = reader.get_merged_coord(Coord(head_rx, i))
if i > monday[1] + 1: if i > monday.col + 1:
if merged is None or x.value == "": if merged is None or x == "":
break break
if merged.width() != 4: if merged.width() != 4:
pprint(f"WARNING: group header witdh !=4 (found: {merged.width()}); blocks !=4 not supported by parser.") pprint(f"WARNING: group header witdh !=4 (found: {merged.width()}); blocks !=4 not supported by parser.")
break break
name = utils.unspace(x.value) name = utils.unspace(x)
groups[name] = { groups[name] = {
"name": name, "name": name,
"position": [head_rx, i], "position": [head_rx, i],

265381
result.json

File diff suppressed because it is too large Load Diff

230
translations.py Normal file
View File

@@ -0,0 +1,230 @@
# --- Абстрактный базовый класс (Контракт) ---
from abc import ABC, abstractmethod
import openpyxl
import xlrd
from coord import Coord, Merged
EMPTY_CTYPES = [xlrd.XL_CELL_EMPTY, xlrd.XL_CELL_BLANK]
class TranschendentnostCell:
def __init__(self, value, is_empty):
self.value = value
self._is_empty = is_empty
def is_empty(self):
self._is_empty
class ExcelSheetReader(ABC):
"""
Абстрактный базовый класс, определяющий интерфейс для чтения данных из листа Excel.
Использует 0-индексированную систему координат, как в xlrd.
"""
def __init__(self, file_path):
self.file_path = file_path
@abstractmethod
def get_cell_value(self, row, col):
"""Возвращает значение ячейки по 0-индексированным координатам."""
pass
@abstractmethod
def get_border_style(self, coord: Coord, side):
"""
Возвращает числовой стиль границы (как в xlrd).
side: 'left', 'right', 'top', 'bottom'
"""
pass
@abstractmethod
def get_merged_cells(self):
"""Возвращает список объединенных ячеек в формате xlrd: [(rlo, rhi, clo, chi), ...]."""
pass
@abstractmethod
def get_row_count(self):
"""Возвращает общее количество строк на листе."""
pass
@abstractmethod
def get_row_values(self, row_index):
"""Возвращает список значений всех ячеек в строке."""
pass
#@abstractmethod
def info(self):
"""Возвращает строку информации"""
return "TODO: info"
@abstractmethod
def cell(self, row, col):
"""Возвращает абстрактную клетку"""
pass
def find(self, query = None):
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)
i += 1
return None
def get_merged(self, rowx: int, colx: int):
"""Даём ей координаты ячейки таблицы а она выдаёт её границы если переданные координаты находятся 'внутри' объединённой ячейки"""
for crange in self.get_merged_cells():
rlo, rhi, clo, chi = crange
chi -= 1
rhi -= 1
if rlo <= rowx <= rhi and chi >= colx >= clo:
return rlo, clo, rhi, chi
# если ячейка не часть объединённых то начала и концы у неё равны.
return rowx, colx, rowx, colx
def get_merged_coord(self, coord: "Coord") -> Merged:
merged = self.get_merged(coord.row, coord.col)
return Merged(coord1=Coord(merged[0], merged[1]), coord2=Coord(merged[2], merged[3]))
# --- Реализация №1: Обертка для xlrd ---
class XlrdSheetReader(ExcelSheetReader):
def __init__(self, file_path, sheet_index=0):
super().__init__(file_path)
self.book = xlrd.open_workbook(file_path, formatting_info=True)
self.sheet = self.book.sheet_by_index(sheet_index)
def get_cell_value(self, row, col):
# Проверка на выход за пределы таблицы, чтобы избежать ошибок
if row < self.sheet.nrows and col < self.sheet.ncols:
return self.sheet.cell_value(row, col)
return None
def info(self):
print("The number of worksheets is {0}".format(self.book.nsheets))
print("Worksheet name(s): {0}".format(self.book.sheet_names()))
return "'{0}': size: {1}x{2} names: ".format(self.sheet.name, self.sheet.nrows, self.sheet.ncols, " ".join(self.book.sheet_names()))
def cell(self, row, col):
"""Возвращает абстрактную клетку"""
c = self.sheet.cell(row, col)
return TranschendentnostCell(c.value, c.ctype in EMPTY_CTYPES)
def get_border_style(self, coord: Coord, side):
row = coord.row
col = coord.col
if row >= self.sheet.nrows or col >= self.sheet.ncols:
return 0 # Нет границы за пределами листа
cell = self.sheet.cell(row, col)
xf = self.book.xf_list[cell.xf_index]
border = xf.border
style_map = {
'left': border.left_line_style,
'right': border.right_line_style,
'top': border.top_line_style,
'bottom': border.bottom_line_style,
}
return style_map.get(side, 0)
def get_merged_cells(self):
return self.sheet.merged_cells
def get_row_count(self):
return self.sheet.nrows
def get_row_values(self, row_index):
if row_index < self.sheet.nrows:
return self.sheet.row_values(row_index)
return []
# --- Реализация №2: Обертка-транслятор для openpyxl ---
class OpenpyxlSheetReader(ExcelSheetReader):
def __init__(self, file_path, sheet_name=None):
super().__init__(file_path)
self.workbook = openpyxl.load_workbook(file_path, data_only=True)
self.sheet = self.workbook[sheet_name] if sheet_name else self.workbook.active
# Словарь для трансляции стилей границ openpyxl в числовые коды xlrd
self.BORDER_STYLE_MAP = {
'thin': 1, 'medium': 2, 'dashed': 3, 'dotted': 4, 'thick': 5,
'double': 6, 'hair': 7, 'mediumDashed': 8, 'dashDot': 9,
'mediumDashDot': 10, 'dashDotDot': 11, 'mediumDashDotDot': 12,
'slantDashDot': 13
}
def _get_cell(self, row, col):
"""Внутренний метод для получения ячейки с преобразованием координат."""
# openpyxl использует 1-индексированную систему
if row < self.sheet.max_row and col < self.sheet.max_column:
return self.sheet.cell(row=row + 1, column=col + 1)
return None
def cell(self, row, col):
"""Возвращает абстрактную клетку"""
c = self._get_cell(row, col)
is_empty = (c.value is None)
return TranschendentnostCell("" if is_empty else c.value, is_empty)
def get_cell_value(self, row, col):
cell = self._get_cell(row, col)
return cell.value if cell else None
def get_border_style(self, coord: Coord, side):
cell = self._get_cell(coord.row, coord.col)
if not cell:
return 0
border_side = getattr(cell.border, side, None)
if border_side:
return self.BORDER_STYLE_MAP.get(border_side.style, 0)
return 0
def get_merged_cells(self):
# Преобразуем формат объединенных ячеек openpyxl в формат xlrd
merged_list = []
for merged_range in self.sheet.merged_cells.ranges:
# Преобразуем 1-индексированные границы в 0-индексированный формат xlrd (rlo, rhi, clo, chi)
# rhi и chi в xlrd являются эксклюзивными (до, но не включая)
rlo = merged_range.min_row - 1
rhi = merged_range.max_row
clo = merged_range.min_col - 1
chi = merged_range.max_col
merged_list.append((rlo, rhi, clo, chi))
return merged_list
def get_row_count(self):
return self.sheet.max_row
def get_row_values(self, row_index):
if row_index < self.sheet.max_row:
# Получаем значения из генератора
return [cell.value for cell in self.sheet[row_index + 1]]
return []
# --- Фабричная функция (Ваша единственная точка входа) ---
def create_reader(file_path, **kwargs) -> ExcelSheetReader:
"""
Создает и возвращает подходящий экземпляр ридера в зависимости от расширения файла.
"""
if file_path.lower().endswith('.xlsx'):
print("Используется движок openpyxl для .xlsx")
return OpenpyxlSheetReader(file_path, **kwargs)
elif file_path.lower().endswith('.xls'):
print("Используется движок xlrd для .xls")
return XlrdSheetReader(file_path, **kwargs)
else:
raise ValueError("Неподдерживаемый формат файла. Используйте .xls или .xlsx")

View File

@@ -2,30 +2,14 @@
# gemini generated # gemini generated
import xlrd import xlrd
from coord import Coord, Merged from coord import Coord, Merged
from translations import ExcelSheetReader
EMPTY_CTYPES = [xlrd.XL_CELL_EMPTY, xlrd.XL_CELL_BLANK] EMPTY_CTYPES = [xlrd.XL_CELL_EMPTY, xlrd.XL_CELL_BLANK]
def border(sh, coord): def has_no_bottom_border(reader: "ExcelSheetReader", coord):
cell = sh.cell(coord.row, coord.col) return reader.get_border_style(coord, 'bottom') == 0 and reader.get_border_style(coord.shift(down=1), 'top') == 0
xf_style: "xlrd.formatting.XF" = sh.book.xf_list[cell.xf_index]
return xf_style.border
def border_right(sh, cell): def parse_all_dirt(reader: "ExcelSheetReader", min_pos, right, down):
return border(sh, cell).right_line_style
def border_left(sh, cell):
return border(sh, cell).left_line_style
def border_bottom(sh, cell):
return border(sh, cell).bottom_line_style
def border_top(sh, cell):
return border(sh, cell).top_line_style
def has_no_bottom_border(sh, coord):
return border_bottom(sh, coord) == 0 and border_top(sh, coord.shift(down=1)) == 0
def parse_all_dirt(sh, min_pos, right, down):
RET = set() RET = set()
row = min_pos.row row = min_pos.row
@@ -33,7 +17,7 @@ def parse_all_dirt(sh, min_pos, right, down):
col = min_pos.col col = min_pos.col
while col < min_pos.col + right: while col < min_pos.col + right:
#print(excel_coordinate(row, col)) #print(excel_coordinate(row, col))
value = str(sh.cell(row, col).value) value = str(reader.get_cell_value(row, col))
if value is not None and len(value) > 0: if value is not None and len(value) > 0:
RET.add(value) RET.add(value)
col += 1 col += 1
@@ -116,24 +100,6 @@ def excel_coordinate(row, col):
return col_str + str(row + 1) # Добавляем номер строки (Excel начинается с 1) return col_str + str(row + 1) # Добавляем номер строки (Excel начинается с 1)
def get_merged(sh, rowx, colx):
"""Даём ей координаты ячейки таблицы а она выдаёт её границы если переданные координаты находятся 'внутри' объединённой ячейки"""
for crange in sh.merged_cells:
rlo, rhi, clo, chi = crange
chi -= 1
rhi -= 1
if rlo <= rowx <= rhi and chi >= colx >= clo:
return rlo, clo, rhi, chi
# если ячейка не часть объединённых то начала и концы у неё равны.
return rowx, colx, rowx, colx
def get_merged_coord(sh, coord):
merged = get_merged(sh, coord.row, coord.col)
return Merged(coord1=Coord(merged[0], merged[1]), coord2=Coord(merged[2], merged[3]))
def merged_humanize(crange): def merged_humanize(crange):
"""Получить из 4 цифр границ AA:BB координаты как в Excel""" """Получить из 4 цифр границ AA:BB координаты как в Excel"""
row_low, col_low, row_high, col_high = crange # see order! row_low, col_low, row_high, col_high = crange # see order!
@@ -141,6 +107,8 @@ def merged_humanize(crange):
def unspace(s: str): def unspace(s: str):
"""Убрать пробелы из текста""" """Убрать пробелы из текста"""
if s is None:
return "!!!Python None!!!"
return s.strip().replace(" ", "").replace("\t", "") return s.strip().replace(" ", "").replace("\t", "")
def find(sh, query = None): def find(sh, query = None):