Привет, Хабр! (И тебе, случайный бухгалтер, который думает, что «выгрузить из банка» - это нажать одну кнопку. И тебе, 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_json
1С шлёт 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-ветке.
Глава 4. ВТБ: банк, который притворяется
Хотелось бы точно так же по RTF-ветке пустить и ВТБ, но… Когда мне прислали файлы ВТБ, кодировка сбивалась и текст выглядел как набор спецсимволов. Кракозябры уровня Ðезерв вместо «Резерв».

Долго гадать почему - не пришлось. Всё стало понятно относительно быстро: судя по всему, ВТБ формирует платёжки в 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 ГБ.
Визуально это выглядело так:

Лечение: потоковая обработка и немедленная сериализация
Решение оказалось концептуально простым, но потребовало переписать ядро обработки. Идея: не держать все платёжки в памяти одновременно. Обработал страницу - сериализовал в 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) с более детальными примерами. Пишите, что интереснее.
Комментарии (8)

aborouhin
21.04.2026 06:21Ох, знакомо. Скажите спасибо, что Вам ещё бинарный .doc не достался, но советую на всякий случай готовиться :)
А ещё бывают банковские выписки, там тоже кто во что горазд (Excel с десятком-другим скрытых столбцов? объединённые вдоль и поперёк ячейки? по ячейке на каждую букву? - легко).

pvzh
21.04.2026 06:21Вот кстати да, больная тема и полный абсурд. XML широко распространён уже 25 лет. На собесе в средний банк спросят про кручение деревьев, но при этом их электронный документооборот вот такой прекрасный. Не так должна выглядеть цифровизация.

denisgrigoriev04
21.04.2026 06:21Как я понимаю, это Сизов труд, поскольку никто не даёт гарантий на структуру документа и может менять её хоть каждый день. Не говоря о том, что изменения будут настолько маленькими, что парсер не сломается, но его результатом будет ошибки Хотя ситуация, где какой-то человек сверяет 2 изображения (как охранник на проходной) похоже останется ещё на коды

MrSotnik Автор
21.04.2026 06:21По этому и разрабатывался, алгоритм который может и при смене структуры всё правильно разложить (ведь он же работает на разных банках, а делался по сути для 3-х а сейчас на нём крутят 10), и плюс ко всему эти платёжки не менялись 10 лет, вряд ли за следующие 10 что-то с ними изменится, ведь банки во многих отношениях староверы.
ip-Voronin
Прямо ЦБшный УФЭБС не могут отдавать? Каждый придумывает свой велосипед?
MrSotnik Автор
Такой прикол документооборота, получил бумажку сначала сам, а потом передал 1С. Я так же задавался вопросом почему та))
ip-Voronin
Я предполагаю, что это, скорее всего, недоработки коммуникации менеджмента/бухгалтерии заказчика и кредитных организаций.
Очень странно для кредитной организации не реализовывать УФЭБС в своём клиент-банке, если они и так обязаны общаться (наверное, всё ещё в КБР-Н) посредством оного с подразделениями ЦБ.
MrSotnik Автор
Всё верно, согласен с вами, но зато спасибо им за крутой кейс))