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

40
main.py
View File

@@ -13,6 +13,7 @@ from requests.structures import CaseInsensitiveDict
from bs4 import BeautifulSoup
import aigenerated
import parser
import translations
import utils
import json
# Общее правило проекта, сначала в координатах идёт ROW а потом COL, нумерация с нуля
@@ -76,41 +77,16 @@ for facultet in FACULTETS:
counter += 1
print(f"\n\n-- Ссылка --")
print(f"{excel_url}")
xlsx = excel_url.endswith(".xlsx")
is_xlsx = excel_url.endswith(".xlsx")
try:
filename = "excels/" + facultet + filestime + f"[C{counter}]" + ".xls"
# Download a file
if not xlsx:
filename = "excels/" + facultet + filestime + f"[C{counter}]" + ".xls" + ("x" if is_xlsx else "")
aigenerated.download_file_from_url(excel_url, filename)
else:
aigenerated.download_file_from_url(excel_url, filename+"x")
excel_file = pd.ExcelFile(filename + "x")
# Создаем "писателя" для формата .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)
reader = translations.create_reader(filename)
parser.LOGGING = False
prs = parser.Parser(sh)
prs = parser.Parser(reader)
prs.parse()
for group_name in prs.groups.keys():
if group_name in result.keys():
@@ -119,15 +95,17 @@ for facultet in FACULTETS:
gr = result[group_name] = prs.groups[group_name]
gr['facultet'] = facultet
gr['data_source'] = excel_url.split("/")[-1] + " SHEET: " + str(sh.name)
gr['data_source'] = excel_url.split("/")[-1]
gr['parser_debug'] = {
"C_COUNTER": counter,
"timestime": filestime,
"excel_url": excel_url,
"sheet": sh.name,
"reader_info": reader.info(),
"filename": filename
}
print(f"Populates {len(prs.groups)} groups to result")
except Exception as e:
print(f"Error while {excel_url}")
print(e)

View File

@@ -4,6 +4,7 @@ import xlrd
import aigenerated
from coord import Coord, Merged
from translations import ExcelSheetReader
import utils
LOGGING = True
@@ -14,27 +15,27 @@ def pprint(*args, **kwargs):
class Parser:
def __init__(self, sheet: "xlrd.sheet.Sheet"):
self.sh: "xlrd.sheet.Sheet" = sheet
def __init__(self, reader: ExcelSheetReader):
self.reader = reader
self.groups = {}
self.teachers = 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):
monday = utils.find(self.sh, "ПОНЕДЕЛЬНИК")
monday = self.reader.find("ПОНЕДЕЛЬНИК")
if monday is None:
print(" -- Failed parse! -- ")
print("ПОНЕДЕЛЬНИК НЕ НАЙДЕН!")
return
head_rx = monday[0] - 1 # выше первого понидельника
head_rx = monday.row - 1 # выше первого понидельника
if 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}")
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("\n\n\n")
@@ -53,12 +54,12 @@ class Parser:
# speaker
low = merged.low
speaker_pos = low.shift(down=merged.height())
speaker = speaker_pos.cell(self.sh).value
speaker = speaker_pos.cell(self.reader).value
# 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):
"""
@@ -70,24 +71,24 @@ class Parser:
pprint(group_name)
row = group['position'][0] + 1 # counter for while, +1 for shift down; также номер строки в таблице (вроде с нуля)
weeknum = 1 # номер недели, щёлкнет +1 при каком-то условии.
while row < self.sh.nrows: # maybe условие чтобы не уйти ниже чем есть строк
pos = Coord(row, group['position'][1]) # текущая позиция, верхний правый угол (=low)
while row < self.reader.get_row_count(): # maybe условие чтобы не уйти ниже чем есть строк
pos = Coord(row, group['position'][1]) # текущая позиция, верхний левый угол (=low)
pos_right = pos.shift(right=3)
pair_pos = pos.replace(col=5)
weekday_pos = pos.replace(col=4)
merged = utils.get_merged_coord(self.sh, pos)
right_cell = pos_right.cell(self.sh)
merged_cell = merged.cell(self.sh)
merged = self.reader.get_merged_coord(pos)
right_cell = pos_right.cell(self.reader)
merged_cell = merged.cell(self.reader)
cv = merged_cell.value
# В конце (12 пара:>) название группы, можно использовать как якорь
if utils.unspace(cv) == group_name:
pprint("Lesson == group name; ending group loop.")
break
weekday_mr = utils.get_merged_coord(self.sh, weekday_pos)
weekday = utils.unspace(weekday_mr.cell(self.sh).value)
pair_mr = utils.get_merged_coord(self.sh, pair_pos)
pair = utils.unspace(pair_mr.cell(self.sh).value)
weekday_mr = self.reader.get_merged_coord(weekday_pos)
weekday = utils.unspace(weekday_mr.cell(self.reader).value)
pair_mr = self.reader.get_merged_coord(pair_pos)
pair = utils.unspace(pair_mr.cell(self.reader).value)
skip = 0
if weekday == "":
@@ -101,7 +102,7 @@ class Parser:
if not skip:
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 = ""
parsed_discipline_name = None
parsed_location = None
@@ -115,7 +116,7 @@ class Parser:
if not is_empty_lesson:
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
is_2pair = True
@@ -124,7 +125,7 @@ class Parser:
parsed_location = ret['loc']
parsed_leader = ret['leader']
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:
@@ -135,7 +136,7 @@ class Parser:
dispname += (" SOLD" if is_solid else " SPLIT")
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}]"
@@ -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)"""
groups = {}
i = 0
while i < len(head):
x = head[i]
pprint(f"while i={i} head[i]={x}")
merged = utils.get_merged_coord(sh, Coord(head_rx, i))
if i > monday[1] + 1:
if merged is None or x.value == "":
merged = reader.get_merged_coord(Coord(head_rx, i))
if i > monday.col + 1:
if merged is None or x == "":
break
if merged.width() != 4:
pprint(f"WARNING: group header witdh !=4 (found: {merged.width()}); blocks !=4 not supported by parser.")
break
name = utils.unspace(x.value)
name = utils.unspace(x)
groups[name] = {
"name": name,
"position": [head_rx, i],

265379
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
import xlrd
from coord import Coord, Merged
from translations import ExcelSheetReader
EMPTY_CTYPES = [xlrd.XL_CELL_EMPTY, xlrd.XL_CELL_BLANK]
def border(sh, coord):
cell = sh.cell(coord.row, coord.col)
xf_style: "xlrd.formatting.XF" = sh.book.xf_list[cell.xf_index]
return xf_style.border
def has_no_bottom_border(reader: "ExcelSheetReader", coord):
return reader.get_border_style(coord, 'bottom') == 0 and reader.get_border_style(coord.shift(down=1), 'top') == 0
def border_right(sh, cell):
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):
def parse_all_dirt(reader: "ExcelSheetReader", min_pos, right, down):
RET = set()
row = min_pos.row
@@ -33,7 +17,7 @@ def parse_all_dirt(sh, min_pos, right, down):
col = min_pos.col
while col < min_pos.col + right:
#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:
RET.add(value)
col += 1
@@ -116,24 +100,6 @@ def excel_coordinate(row, col):
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):
"""Получить из 4 цифр границ AA:BB координаты как в Excel"""
row_low, col_low, row_high, col_high = crange # see order!
@@ -141,6 +107,8 @@ def merged_humanize(crange):
def unspace(s: str):
"""Убрать пробелы из текста"""
if s is None:
return "!!!Python None!!!"
return s.strip().replace(" ", "").replace("\t", "")
def find(sh, query = None):