Сегодня расскажу, как мне поставили задачу, от которой у SAP-а ушло, видимо, несколько команд и много времени, а мне дали на это… ну, скажем так, поменьше. Задача звучала элегантно, но всегда есть но, и не одно))(Спойлер для тех, кому лень читать: я узнал, что Сбербанк формирует WORD-документы с такой XML-вложенностью, что в ней можно заблудиться, ВТБ зачем-то маскирует WORD под RTF, а файл на 10 000 платёжек из 37 мегабайт разворачивается в 1 гигабайт XML. И да, всё по итогу заработало.)
Привет, Хабр! (И тебе, случайный бухгалтер, который думает, что «выгрузить из банка» - это нажать одну кнопку. И тебе, 1С-разработчик, который слышит «парсинг PDF» и сразу уходит на больничный. И тебе, Python-разработчик, который уверен, что pip install magic_solution решит любую проблему.)
Сегодня расскажу, как мне поставили задачу, от которой у SAP-а ушло, видимо, несколько команд и много времени, а мне дали на это… ну, скажем так, поменьше. Задача звучала элегантно, но всегда есть но, и не одно))
(Спойлер для тех, кому лень читать: я узнал, что Сбербанк формирует WORD-документы с такой XML-вложенностью, что в ней можно заблудиться, ВТБ зачем-то маскирует WORD под RTF, а файл на 10 000 платёжек из 37 мегабайт разворачивается в 1 гигабайт XML. И да, всё по итогу заработало.)
Глава 0. Предыстория: откуда вообще взялась эта задачаМесяц назад от компании заказчика поступил запрос, который начинался совершенно безобидно:
Мы уходим из SAP.
Логично. На дворе 2026 год, импортозамещение, всё понятно.
Но у нас там была разработка, которая парсила платёжные поручения всех основных российских банков. На вход - документ с 10, 100, 1000 или 10 000 платёжками. На выходе - разобранная информация по каждой платёжке и сам документ каждой платёжки отдельным листом. PDF, WORD, RTF - всё принималось. И теперь мы хотим то же самое, только лучше, быстрее и точнее. В 1С.
Звучит как «мы хотели бы, чтобы кошка лаяла, но оставалась кошкой». Знакомо?
Как вы уже, наверное, догадались, статья не про 1С-решение. После того, как 1С-разработчики покрутили эту задачу с разных сторон, был вынесен вердикт: «Реализация на встроенном языке 1С будет слишком долгой, муторной и не будет соответствовать требованию “максимально быстро”». И тут выхожу на сцену я - человек с опытом разных интеграций, которого подключают к проекту со словами «ну ты же знаешь Python, а ещё у тебя API-шки есть…»

Платёжное поручение
И вот здесь начинается всё веселье...
Сразу было решено: пишем API на Python, крутимся на линуксовом сервере, принимаем файлы из 1С, разбираем их и отправляем обратно красивый JSON. Бухгалтеры шлют платёжки из 1С - API их парсит - 1С получает результат. Все довольны. В теории.
Глава 1. Архитектура: Flask, base64 и «прилетело из 1С»Для начала - выбор инструмента. Я взял Flask как идеально подходящую библиотеку для наших задач. Здесь не будет прям огромной многопоточности - всего 2–3 бухгалтера, которые раз в месяц усиленно будут слать из 1С документы. Плюс простота самой библиотеки - а ещё тело части кода можно было спокойно взять из моего предыдущего проекта Битрикс-бота для скорой реализации. (Да, у меня есть Битрикс-бот. Нет, о нём - в другой статье.)
Основная точка входа выглядит просто и лаконично:
@app.route('/parsing_file/pars', methods=['GET', 'POST'])
def handle_webhook():
"""Обрабатывает входящие запросы"""
if request.method == 'POST':
data = request.get_json(force=True, silent=True) or request.form.to_dict()
return get_file(data)
else:
return jsonify({"status": "ready v20.04.2026.11:00"})
def get_file(data):
base64_content = data.get("FileData")
mapping_type = data.get("MappingType")
payer_account = data.get("PayerAccount")
docx_bytes = base64.b64decode(base64_content)
result_json = Pars.start_pars_doc(docx_bytes, mapping_type, payer_account,
log=True, save=False, local=False)
return result_json1С шлёт POST с тремя полями: файл в base64, тип банка (маппинг) и счёт плательщика. API декодирует, парсит и возвращает JSON. Всё. Чистота и красота.
Но, как это часто бывает в IT, реальность внесла коррективы. И началось это с самого первого банка.
Глава 2. Газпромбанк и WORD: первая кровьДля начала нужно было за рабочую неделю собрать парсинг платёжек по Газпромбанку и презентовать заказчику, чтобы они видели ход работы и могли параллельно уже подгружать в 1С поручения.
Газпромбанк даёт свои выписки в формате WORD. Окей, берём библиотеку python-docx - она умеет работать с текстом и таблицами. Для теста дали файл чуть более чем на 100 платёжек. Им я и занялся.
Первый прогон. И тут - первая жёсткая проблема. Далеко не последняя…
XML-матрёшка Газпромбанка, Сбербанка (и не только)Чтобы понять масштаб катастрофы, нужно знать одну важную вещь: формат .docx - это, по сути, ZIP-архив с XML внутри. Когда вы открываете .docx через python-docx, библиотека разархивирует его и работает с XML-деревом.
И вот табличная структура, из которой я планировал собирать информацию по каждой платёжке, оказалась немыслимо вложенной.
Давайте посмотрим на реальный XML выписки Газпромбанка. Вот что видит человек в ворде: аккуратная таблица с колонками «Вид ограничения», «Очередность», «Начало действия», «Сумма», «Основание»... Просто таблица, правда?

А вот что видит парсер:
<w:tbl> <!-- Внешняя таблица страницы -->
<w:tr> <!-- Строка внешней таблицы -->
<w:tc> <!-- Ячейка, занимающая 11 колонок -->
<w:tbl> <!-- Вложенная таблица (сама выписка) -->
<w:tr> <!-- Строка с заголовками -->
<w:tc> <!-- Ячейка "Вид ограничения" -->
<w:tbl> <!-- ЕЩЁ ОДНА вложенная таблица! -->
<w:tr>
<w:tc> <!-- И вот тут, наконец, текст -->
<w:p>
<w:r>
<w:t>Вид ограничения</w:t>
</w:r>
</w:p>
</w:tc>
</w:tr>
</w:tbl>
</w:tc>
<!-- Повторите для КАЖДОЙ колонки... -->
</w:tr>
</w:tbl>
</w:tc>
</w:tr>
</w:tbl>Вы не ослышались: таблица - внутри ячейки - таблица - внутри ячейки - таблица - и только потом текст. Три уровня вложенности! Причём это не я так придумал - это банки так генерирует свои WORD-выписки. Каждая ячейка каждой строки - это отдельная таблица в отдельной таблице.
Более того, структура сетки (<w:tblGrid>) корневой таблицы содержит 20 колонок разной ширины (от 40 до 4000 dxa), но реальные ячейки активно используют gridSpanдля объединения - то есть одна визуальная ячейка может занимать от 2 до 17 колонок сетки. На каждой новой странице документа эта конструкция полностью повторяется с заголовками: дата, «СберБизнес», таблица с ограничениями, номер страницы…
Перевод на человеческий: Баки генерируют документы так, будто Word - это HTML из 2003 года, где каждую ячейку оборачивают в отдельный <table> «для надёжности». - Возможно это и есть HTML изначально, кто ж знает...
Поэтому обычный table.rows[i].cells[j].text здесь не работает. Пришлось писать рекурсивный обход XML:
def parse_nested_table(tbl, mapping_type, num_cols, date_pat):
"""Парсит вложенную таблицу, используя конфиг банка по числу столбцов."""
config = C.BANK_CONFIGS.get(mapping_type)
if not config: return []
col_order = config["col_order"]
rows = []
for tr in tbl.findall(f"{{{C.W_NS}}}tr"):
tcs = tr.findall(f"{{{C.W_NS}}}tc")
if len(tcs) != num_cols: continue
cell_values = []
for tc in tcs:
# Ключевой момент: ищем вложенную таблицу внутри ячейки
inner_tbl = tc.find(_W_TBL_TAG)
if inner_tbl is not None:
cell_values.append(get_xml_text(inner_tbl))
else:
cell_values.append(get_xml_text(tc))
if not date_pat.match(cell_values[0]): continue
row = _map_row(cell_values, col_order)
if row is not None: rows.append(row)
return rowsФункция get_xml_text при этом пробегает по всему поддереву XML-элемента, собирая текст из <w:t> тегов и заменяя <w:br> на пробелы:
def get_xml_text(element):
"""Парсим xml в текст"""
parts = []
for node in element.iter():
tag = node.tag
if tag == _W_T_TAG: parts.append(node.text or "")
elif tag == _W_BR_TAG: parts.append(" ")
return "".join(parts).strip()Проблема со стилями: поплывший текстВсё получилось, результат есть! Далее из инфы таблицы собираем платёжки, кодируем их в base64 и шлём обратно в 1С радовать бухгалтеров. И тут - новое препятствие.
Представьте: у вас есть блоки элементов, которые необходимо упаковать в новый WORD-документ. Вы реализуете это, запускаете, смотрите на результат и видите… поплывший текст, не тот шрифт, отсутствие таблиц. Короче - визуальная каша вместо аккуратной платёжки.

Пример того как выглядит каждая платёжка в оригинале
Причина: при копировании XML-элементов в новый документ теряются стили исходного документа - шрифты, размеры, отступы, свойства секции (ориентация страницы, поля).
Первоначальное решение - копировать стиль каждой страницы из тела XML. Но на этапе оптимизации я ушёл от этого в сторону единичного копирования всех стилей документа и кэширования для дальнейшего использования. Все платёжки в одном документе идут с одним и тем же стилем - проводить все эти операции раз за разом бессмысленно. Берите на заметку оптимизации:
# Кэш параметров секций
_SECTION_PROPS_CACHE = {}
def _save_payment_doc(source_doc, page_elements, mapping_type=None, ...):
new_doc = Document()
# Кэшируем стили секции один раз на весь маппинг
if not _SECTION_PROPS_CACHE.get(mapping_type):
_SECTION_PROPS_CACHE[mapping_type] = _find_section_for_page(
source_doc, page_elements
)
# ... клонируем элементы ...
# Копируем параметры секции из кэша
section_cache = _SECTION_PROPS_CACHE[mapping_type]
tgt = new_doc.sections[0]
for attr in ("orientation", "top_margin", "bottom_margin",
"left_margin", "right_margin", "page_width", "page_height"):
val = getattr(section_cache, attr, None)
if val is not None: setattr(tgt, attr, val)Заказчик рад. Газпром работает, Сбер тоже встал на рельсы по той же WORD-ветке. Идём дальше.
Глава 3. RTF: ДомРФ и пиксельная арифметикаДальше идём к RTF.
Банк ДомРФ, как и ВТБ, даёт свои выписки в формате RTF - доисторическом формате, где все значения цепляются к таблице не как мы привыкли видеть обычно, а блоками. Объяснить сложно, поэтому опишу суть: слой таблицы и слой значений этой таблицы находятся в совершенно разных местах документа. Данные «плавают» как VML-объекты, позиционированные через margin-left и margin-top в стилях.

Вот как плавают данные
Да, я пытался трансформировать RTF в WORD напрямую. Технически это реально сделать с помощью pywin32 или LibreOffice, но:
Сервер с API на Linux. Для LibreOffice надо устанавливать множество зависимостей, которые будут крутиться в фоне Такого костыльного формата я не люблю. Поэтому выбрал иной костыль - парсить VML-текстбоксы по координатам:
# Границы колонок (landscape page, первая страница выписки DOMRF)
_DOMRF_COL_BOUNDS = [
(0, 50), # 0 Дата (left ≈ 19)
(50, 110), # 1 № док. (left ≈ 71)
(110, 155), # 2 ВО (left ≈ 143)
(155, 210), # 3 Банк контр. (left ≈ 165)
(210, 310), # 4 Контрагент (left ≈ 235)
(310, 420), # 5 Счёт контр. (left ≈ 350)
(420, 500), # 6 Дебет (left ≈ 450)
(500, 600), # 7 Кредит (left ≈ 530)
(600, 720), # 8 Назначение (left ≈ 627)
(720, 900), # 9 УИП (left ≈ 717)
]Что тут происходит: мы извлекаем все VML-текстбоксы из документа, читаем их координаты margin-left и margin-top, группируем по строкам (с допуском в 6 пикселей) и раскладываем по колонкам по горизонтальным границам. Если в строке есть дата в колонке 0 - это наша строка данных.
def parse_domrf_vml_rows(body, date_pat):
"""Извлекает строки данных выписки DOMRF из VML-текстбоксов."""
boxes = _extract_vml_textboxes(body)
if not boxes: return []
pages = _split_boxes_into_pages(boxes, reset_threshold=50.0)
result = []
for page_boxes in pages:
row_groups = _group_boxes_into_rows(page_boxes, tolerance=6.0)
for top_key in sorted(row_groups):
group = row_groups[top_key]
# Есть ли бокс с датой dd.mm.yyyy И left < 50?
has_date_in_col0 = any(
date_pat.match(b["text"]) and b["left"] < 50
for b in group
)
if not has_date_in_col0: continue
cells = [""] * ncols
for box in sorted(group, key=lambda b: b["left"]):
col_idx = _assign_box_to_column(box["left"], C._DOMRF_COL_BOUNDS)
if col_idx is None: continue
if not cells[col_idx]: cells[col_idx] = box["text"]
result.append(cells)
return resultДа, знатный костыль. Но работает. А дальше - конвертируем RTF в WORD через spire.doc (платная библиотека, но бесплатного функционала хватило) и извлекаем платёжки уже по стандартной WORD-ветке.
Хотелось бы точно так же по RTF-ветке пустить и ВТБ, но… Когда мне прислали файлы ВТБ, кодировка сбивалась и текст выглядел как набор спецсимволов. Кракозябры уровня Ðезерв вместо «Резерв».

Пример из переписки с разработчиком 1С
Долго гадать почему - не пришлось. Всё стало понятно относительно быстро: судя по всему, ВТБ формирует платёжки в WORD, а затем топорно меняет расширение на .rtf.
У вас может возникнуть вопрос: «Зачем?» У меня он тоже возник. И я не знаю. Все вопросы к разработчикам ВТБ. Я их долго ругал у себя в голове.
Но решение оказалось простым: в 1С умельцы сделали обратную конвертацию в WORD перед отправкой на API - и всё поехало по ветке Газпромбанка (WORD-ветка).
Глава 5. PDF: логово Альфы, МКБ и СовкомаТам тусуются много ребят: Альфа-Банк, МКБ, Совкомбанк и ещё несколько.
Здесь мне на помощь пришли две библиотеки:
pdfplumber - для таблиц (тяжёленькая, долго обрабатывает, но у неё есть ценный навык работы с таблицами, а выписка - это и есть таблица)
PyPDF2 - для всего остального (получение текста страницы для маппинга платёжки, формирование документа конкретной платёжки)
def _extract_from_tables(page, col_keys):
"""Извлекает данные из таблиц на странице."""
tables = page.extract_tables(C.TABLE_CONFIG)
if not tables: return []
rows = []
col_count = len(col_keys)
for table in tables:
for row_cells in table:
if not row_cells: continue
cleaned = [_clean_cell(cell) for cell in row_cells]
if len(cleaned) >= col_count:
row = _map_row(cleaned[:col_count], col_keys)
elif len(cleaned) >= 4:
if is_valid_date(cleaned[0].strip()):
row = _map_row(cleaned, col_keys)
else: continue
else: continue
if row: rows.append(row)
return rowsЯ, конечно, не терял надежды (точнее, не я - а мой внутренний перфекционист) превратить PDF в WORD и вести весь парсинг по одной ветке. Но, к примеру, Альфа-Банк из-за своих SVG-изображений логотипов не давался и гробил весь документ при конвертации. Поэтому было принято решение: максимум работы делаем на PDF-ветке, а повторяющуюся логику маппинга и поиска платёжек выносим в общую базу.
Глава 6. Универсальный конфиг: как не утонуть в банкахК этому моменту у нас уже накопилось множество банков, и каждый со своей структурой колонок. Чтобы не писать отдельный парсер для каждого, я сделал единый конфиг:
COMMON_COLUMNS = {
"DocDate": ["Дата док.", "Дата проводки", "Дата", ...],
"DocNumber": ["№ докум.", "№ документа", "№ док", ...],
"DebitTurnover": ["Оборот по дебету", "Сумма по дебету", "Дебет"],
"CreditTurnover": ["Оборот по кредиту", "Сумма по кредиту", "Кредит"],
"PaymentPurpose": ["Назначение платежа", "Назначение", ...],
# ... и так для каждого поля
}
# Порядок колонок для каждого банка
_GPB_COL_ORDER = ["DocDate", "VO", "DocNumber", "CounterpartyBankBIC", ...]
_SBER_COL_ORDER = ["DocDate", "DebitAccount", "CreditAccount", ...]
_ALFA_COL_ORDER = ["DocDate", "DocNumber", "DebitTurnover", ...]
# ... ещё 7 банковПри инициализации модуля автоматически строятся конфиги для всех банков:
def _build_bank_configs():
for ncols, col_order, bank_idx in [
("GPB", _GPB_COL_ORDER, 0),
("SBER", _SBER_COL_ORDER, 1),
("ALFA", _ALFA_COL_ORDER, 5),
# ...
]:
ru_names = []
for en_key in col_order:
variants = COMMON_COLUMNS[en_key]
ru_name = variants[bank_idx] if bank_idx < len(variants) else variants[0]
ru_names.append(ru_name)
BANK_CONFIGS[ncols] = {"col_order": col_order, "ru_names": ru_names}
_build_bank_configs() # Запускаем один раз при импортеТеперь добавить новый банк = добавить одну строку с порядком колонок. Никакого дублирования логики.
Глава 7. Поиск платёжек: детективная работаОтдельная песня - сопоставление строк выписки с конкретными страницами платёжных поручений в документе. Одно дело - распарсить таблицу выписки и получить {"DocNumber": "44", "DebitTurnover": 117134.49, ...}. Другое дело - найти на какой странице документа лежит именно это платёжное поручение №44 на 117 134,49 руб.
Алгоритм:
Разбиваем документ на «страницы» по <w:br type="page"> и <w:lastRenderedPageBreak>
Для каждой страницы извлекаем полный текст
Ищем на странице паттерн платёжки (ПЛАТЁЖНОЕ ПОРУЧЕНИЕ, ИНКАССОВОЕ ПОРУЧЕНИЕ, БАНКОВСКИЙ ОРДЕР и т.д.)
Сверяем поля строки выписки с текстом страницы
PAYMENT_PATTERN = re.compile(
r"(?:ПЛАТ[ЕЁ]ЖНОЕ+ПОРУЧЕНИЕ|БАНКОВСКИЙ+ОРДЕР|ИНКАССОВОЕ+ПОРУЧЕНИЕ|"
r"МЕМОРИАЛЬНЫЙ+ОРДЕР|ПЛАТ[ЕЁ]ЖНЫЙ+ОРДЕР|ПЛАТ[ЕЁ]ЖНОЕ+ТРЕБОВАНИЕ)",
)
def _match_page_to_rows(page_text, row_lookup, number_with_name=None):
"""Возвращает список записей, соответствующих тексту страницы."""
clean_spaces_page_text = clean_spaces(page_text)
clean_simple_page_text = simple_clean(clean_spaces_page_text)
matched = []
for entry in row_lookup:
primary = entry["primary"] # Номер документа
# Быстрая проверка: номер должен быть в тексте
if primary not in page_text: continue
# Проверка привязки номера к названию платёжки
if number_with_name_clean:
if number_with_name_clean + primary not in clean_spaces_page_text:
continue
# Все дополнительные поля должны присутствовать
extra = entry["extra"]
if not all(v and v in clean_simple_page_text
for v in extra.values()):
continue
matched.append(entry)
entry["matched"] = True
return matchedПочему так сложно? Потому что на одной странице может быть несколько платёжек с одинаковым номером (привет, дубли между разными банками). Поэтому проверяется не только номер, но и сумма, и назначение платежа, и привязка номера к типу документа.
Глава 8. Проблема на финише: 10 000 платёжек и 18 ГБ оперативкиВсё работает. Ура. Мы на инициативе и воодушевлении. И тут нам дают файл с 10 000 платёжек по Газпромбанку и говорят: «Хотим это».
Небольшой экскурс в анатомию WORD я уже провёл в разделе XML-матрёшка Газпромбанка, Сбербанка (и не только).
Ключевая особенность: docx-файл может весить 37 МБ на 10 000 платёжек, но в XML он разрастается в 1 ГБ.
Представьте, что на своём обычном компьютере вы открываете файл размером 1 ГБ и пытаетесь его прочесть. Жесть, да? Я даже не смог открыть этот документ на своём компьютере в обычном Word.
Вот и алгоритм от этого был в шоке:
Во-первых, это долго
Во-вторых, всё падает в оперативную память
По замерам на тестовом сервере, обработка файла на 10 000 платёжек в лоб (загрузить весь XML в память, пройтись по всем элементам, собрать все страницы, для каждой создать отдельный Document) съедала около 18 ГБ оперативной памяти и работала 45+ минут. На продакшн-сервере с 32 ГБ RAM и ещё с другими запущенными сервисами это означало одно: OOM Killer приходил быстрее, чем бухгалтер успевал налить чай.
Диагноз: смерть от копированияГлавный пожиратель памяти - создание отдельного WORD-документа для каждой платёжки. Каждый вызов Document() - это новый XML-документ в памяти. Каждое клонирование элементов через deepcopy - это полная копия XML-поддерева со всеми атрибутами, стилями и пространствами имён. Умножьте это на 10 000 - и вот ваши 18 ГБ.
Визуально это выглядело так:

Процессы на Linux
Лечение: потоковая обработка и немедленная сериализацияРешение оказалось концептуально простым, но потребовало переписать ядро обработки. Идея: не держать все платёжки в памяти одновременно. Обработал страницу - сериализовал в base64 - записал в результат - освободил память. Следующая.
def _process_pages_streaming(source_doc, pages, row_lookup, mapping_type, ...):
"""Потоковая обработка: одна платёжка за раз, без накопления в памяти."""
results = []
for page_idx, page_elements in enumerate(pages):
# 1. Извлекаем текст страницы (лёгкая операция)
page_text = _get_page_text(page_elements)
# 2. Ищем совпадение со строкой выписки
matched = _match_page_to_rows(page_text, row_lookup)
if not matched:
continue
# 3. Создаём документ ОДНОЙ платёжки
single_doc = _save_payment_doc(source_doc, page_elements, mapping_type)
# 4. СРАЗУ сериализуем в base64
buf = BytesIO()
single_doc.save(buf)
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
buf.close()
# 5. Записываем результат
for entry in matched:
entry["row"]["FileData"] = b64
results.append(entry["row"])
# 6. Освобождаем память НЕМЕДЛЕННО
del single_doc
del buf
del b64
# Каждые 500 платёжек — принудительная сборка мусора
if page_idx % 500 == 0:
gc.collect()
return resultsОбратите внимание на gc.collect() каждые 500 итераций. В обычной ситуации вызывать сборщик мусора вручную - моветон. Но при обработке 10 000 документов, каждый из которых создаёт временный XML-объект на мегабайт-два, сборщик мусора Python не всегда успевает за вами. Явный gc.collect() - это как сказать уборщику: «Нет, не через час. Сейчас. Вот прямо сейчас убери этот стол, потому что следующий клиент уже на пороге.»
Метрика | До оптимизации | После оптимизации |
RAM (пик) | ~18 ГБ | ~1.2 ГБ |
Время (10 000 платёжек) | 45+ мин (падало) | ~12 мин, а затем после ещё доп. оптимизации ~6 мин |
Стабильность | OOM на 3000-й | Стабильно до конца |
Ещё раз Ура. Сервер перестал падать. Бухгалтеры перестали звонить. Я перестал просыпаться ночью в холодном поту от слова «OutOfMemory».
Ещё одна оптимизация, которая дала ощутимый прирост - предварительная разметка всего документа на страницы одним проходом вместо повторного сканирования. Документ на 10 000 платёжек содержит десятки тысяч XML-элементов. Каждый раз искать <w:br type=“page”> от начала - убийственно.
def _split_body_into_pages(body):
"""Разбивает тело документа на страницы за один проход по XML."""
pages = []
current_page = []
for element in body:
# Проверяем наличие разрыва страницы В элементе
has_break = _element_has_page_break(element)
if has_break and current_page:
pages.append(current_page)
current_page = []
current_page.append(element)
if current_page:
pages.append(current_page)
return pages
def _element_has_page_break(element):
"""Проверяет, содержит ли элемент разрыв страницы."""
for br in element.iter(_W_BR_TAG):
if br.get(_W_TYPE_ATTR) == "page":
return True
for rendered in element.iter(_W_LAST_RENDERED_TAG):
return True
return FalseОдин проход - и у нас есть список из 10 000+ «страниц», каждая из которых - просто список ссылок на XML-элементы. Никакого копирования данных, только ссылки. Дальше работаем с конкретной страницей по индексу.
Глава 9. Финальная архитектура: как это всё собралось воединоПосле всех битв с форматами, банками и оперативной памятью, архитектура выстроилась в достаточно чёткую схему. Давайте посмотрим на неё сверху.
Точка входа: один эндпоинт - все банки1С → POST /parsing_file/pars → Flask API
{
"FileData": "<base64>",
"MappingType": "GPB" | "SBER" | "ALFA" | "VTB" | "DOMRF" | ...,
"PayerAccount": "407..."
}MappingType - ключевой параметр. Именно он определяет, по какой ветке пойдёт обработка и какой конфиг колонок использовать.
def start_pars_doc(doc_bytes, mapping_type, payer_account, **kwargs):
"""Главный роутер: определяет формат и запускает нужный парсер."""
# Определяем формат по сигнатуре файла
if doc_bytes[:4] == b'PK': # ZIP-сигнатура = DOCX
return _process_docx(doc_bytes, mapping_type, payer_account, **kwargs)
elif doc_bytes[:5] == b'%PDF-': # PDF
return _process_pdf(doc_bytes, mapping_type, payer_account, **kwargs)
elif doc_bytes[:5] == b'{\\rtf': # RTF
return _process_rtf(doc_bytes, mapping_type, payer_account, **kwargs)
else:
return jsonify({"error": "Неизвестный формат файла"})Да, мы определяем формат не по расширению файла (которое, как мы выяснили с ВТБ, может врать), а по магическим байтам в начале файла. Это тот урок, за который я заплатил несколькими часами отладки.
Три ветки - одна логикаDOCX-ветка (Газпромбанк, Сбербанк, ВТБ*, Росбанк, РСХБ, Промсвязь...):
→ python-docx → XML-парсинг → вложенные таблицы → конфиг банка → маппинг
→ разбивка на страницы → поиск платёжек → base64 → JSON
PDF-ветка (Альфа-Банк, МКБ, Совкомбанк, Точка...):
→ pdfplumber (таблицы) + PyPDF2 (текст/страницы) → конфиг банка → маппинг
→ разбивка на страницы → поиск платёжек → base64 → JSON
RTF-ветка (ДомРФ):
→ VML-парсинг координат → пиксельная раскладка по колонкам → конфиг банка
→ конвертация в DOCX (spire.doc) → далее как DOCX-ветка для платёжек
* ВТБ: конвертируется из фейкового RTF в DOCX на стороне 1СОбщие модулиВне зависимости от ветки, все парсеры используют:
Единый конфиг банков (BANK_CONFIGS) - порядок колонок, русские названия, количество столбцов
Единый маппер (_map_row) - превращает массив значений ячеек в словарь с ключами DocDate, DocNumber, DebitTurnover и т.д.
Единый поиск платёжек (_match_page_to_rows) - сопоставление строк выписки со страницами документа
Единый сериализатор - формирование итогового JSON с base64-документами
def _map_row(cell_values, col_order):
"""Универсальный маппер: массив ячеек → словарь по конфигу банка."""
row = {}
for i, key in enumerate(col_order):
if i < len(cell_values):
value = cell_values[i].strip()
# Нормализация сумм: "1 234,56" → "1234.56"
if key in ("DebitTurnover", "CreditTurnover", "Amount"):
value = _normalize_amount(value)
row[key] = value
return row if _is_valid_row(row) else NoneГлава 10. Уроки, грабли и выводыОглядываясь назад на эту эпопею, я собрал коллекцию граблей, на каждые из которых наступил минимум дважды. Делюсь, чтобы вы наступили хотя бы один раз.
Грабли №1:
Не доверяй расширению файла ВТБ нас этому научил. Файл .rtf может оказаться переименованным .docx. Файл .doc может оказаться RTF внутри. Всегда проверяйте магические байты. PK = ZIP (DOCX), %PDF- = PDF, {\rtf = RTF. Всё остальное - подозрительно.
Грабли №2:
«Простая таблица» в WORD - это оксюморон Если вы думаете, что table.rows[i].cells[j].text - это всё, что нужно для парсинга таблиц в WORD, то вы ещё не встречали документы Сбербанка. Таблица внутри ячейки внутри таблицы внутри ячейки - это не баг, это фича генератора выписок. Всегда будьте готовы к рекурсивному обходу XML.
Грабли №3:
Память - не бесконечна (сюрприз!) Создавать 10 000 объектов Document() в цикле и надеяться, что gc справится сам - наивно. Потоковая обработка (создал - сериализовал - удалил - следующий) не просто экономит память - она делает разницу между «работает» и «падает».
Грабли №4:
RTF в 2025 году - это боль RTF - формат из эпохи, когда «позиционирование» означало «margin-left в пикселях». Парсить его нативно на Python - занятие для тех, кто любит страдать. Если есть возможность сконвертировать в DOCX или PDF до обработки - делайте это. Я пробовал оба пути. Конвертация выигрывает.
Грабли №5:
Бухгалтеры всегда найдут edge case Вы думаете, что учли все форматы? А потом приходит платёжка, где в поле «Назначение платежа» - четыре абзаца текста с переносами строк, и ваш парсер решает, что это четыре разные платёжки. Или номер документа б/н (без номера), который ломает поиск по числовому паттерну. Или сумма 0.00 в дебете, которую вы фильтруете как пустую строку.
Тестируйте на реальных данных. Потом ещё раз. Потом ещё.
Вывод (и немного морали)Когда мне описали эту задачу, я думал: «Ну парсинг документов, что тут сложного? Берём библиотеку, читаем таблицу, маппим поля». Почти месяца спустя я знаю устройство формата DOCX лучше, чем устройство своего компьютера.
Что я вынес из этого проекта:
Каждый банк - это отдельная вселенная со своими правилами. Газпромбанк, Сбербанк, Альфа, ВТБ, ДомРФ - все они генерируют документы по-разному. И «стандарт» здесь - слово, которое каждый трактует по-своему.
Универсальный конфиг - это спасение. Без него каждый новый банк — это +500 строк кода. С ним - +1 строка конфигурации и (может быть) пара правок в парсере.
Оптимизация - это не про «сделать красиво». Это про «чтобы не падало на 10 000 записей и укладывалось в 8 ГБ RAM продакшн-сервера».
1С и Python - отличная связка. 1С берёт на себя бизнес-логику, интерфейс и работу с пользователем. Python - тяжёлый парсинг, работу с форматами и то, что на встроенном языке 1С делать больно. API между ними - тонкий мост, но он работает.
SAP-овская команда, наверное, прошла тот же путь. И, скорее всего, ругалась теми же словами, только на немецком.
В итоге система работает в продакшне, бухгалтеры загружают выписки из 1С, получают обратно разобранные платёжки с документами, и никто не подозревает, что под капотом - рекурсивный обход XML-матрёшек, пиксельная арифметика VML-боксов и принудительная сборка мусора каждые 500 итераций.
А процессор, как мы помним, всё так же тупо и быстро перекладывает нули и единицы. Просто теперь он делает это во имя бухгалтерии.
Огромное спасибо всем тем кто дочитал статью до этого момента, простите что получилось так много, я старался рассказать всё. Этот проект мне очень понравился своей непредсказуемостью и отсутствием каких либо решений в сети, по этому я делюсь с вами многими моментами которые пощекотали мои нервишки. Надеюсь вам понравился изложенный опыт.
P.S. Немного о цифрах. Писал статью я на протяжении всей разработки... писал, переписывал и так ровно 21 день, что бы поделиться интересным кейсом. Начал писать с 32 зубами, а закончил с 31)) Здесь долгая история...
P.P.S. Нет, я не буду выкладывать полный исходный код. Но если будет интерес - могу написать отдельную статью про конкретную ветку (DOCX/PDF/RTF) с более детальными примерами. Пишите, что интереснее.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Как я автоматизировал юридическую рутину | 0 | 11.8 | 09-03-2026 |
| 2 | Переиспользуем Python-функции между проектами — без переписывания и передеплоя | 0 | 13.55 | 14-07-2026 |
| 3 | Как мы отдали искусственный интеллект в «рабство» бухгалтерии и что из этого вышло | 0 | 8.42 | 10-08-2026 |
| 4 | Контрольные суммы ИНН, ОГРН и СНИЛС: разбираем алгоритмы и пишем валидатор на Python | 0 | 8.26 | 16-06-2026 |
| 5 | miniword: A WYSIWYG Word Processor in Python | 0 | 10 | 07-05-2026 |
| 6 | От ассистентов к агентам: Сбербанк вывел разработку программного кода на промышленный уровень | 5 | 7 | 19-06-2026 |
| 7 | Как я ML-ку делал | 0 | 9.12 | 01-02-2026 |
| 8 | Мечтают ли папки с заметками об отдельном бэкенде? От ресерча к MVP | 0 | 11.04 | 21-03-2026 |
| 9 | Хватит парсить Excel вручную: я написал библиотеку, которая сделает это за вас | 0 | 16.28 | 03-02-2026 |
| 10 | Греф: Сбербанк готов к штрафам за неподключение в срок к Системе быстрых платежей ЦБ | 0 | 0 | 05-09-2019 |