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

Пример из переписки с разработчиком 1С
Пример из переписки с разработчиком 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 руб.

Алгоритм:

  1. Разбиваем документ на «страницы» по <w:br type="page"> и <w:lastRenderedPageBreak>

  2. Для каждой страницы извлекаем полный текст

  3. Ищем на странице паттерн платёжки (ПЛАТЁЖНОЕ ПОРУЧЕНИЕ, ИНКАССОВОЕ ПОРУЧЕНИЕ, БАНКОВСКИЙ ОРДЕР и т.д.)

  4. Сверяем поля строки выписки с текстом страницы

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
Процессы на 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 лучше, чем устройство своего компьютера.

Что я вынес из этого проекта:

  1. Каждый банк - это отдельная вселенная со своими правилами. Газпромбанк, Сбербанк, Альфа, ВТБ, ДомРФ - все они генерируют документы по-разному. И «стандарт» здесь - слово, которое каждый трактует по-своему.

  2. Универсальный конфиг - это спасение. Без него каждый новый банк — это +500 строк кода. С ним - +1 строка конфигурации и (может быть) пара правок в парсере.

  3. Оптимизация - это не про «сделать красиво». Это про «чтобы не падало на 10 000 записей и укладывалось в 8 ГБ RAM продакшн-сервера».

  4. 1С и Python - отличная связка. 1С берёт на себя бизнес-логику, интерфейс и работу с пользователем. Python - тяжёлый парсинг, работу с форматами и то, что на встроенном языке 1С делать больно. API между ними - тонкий мост, но он работает.

  5. SAP-овская команда, наверное, прошла тот же путь. И, скорее всего, ругалась теми же словами, только на немецком.

В итоге система работает в продакшне, бухгалтеры загружают выписки из 1С, получают обратно разобранные платёжки с документами, и никто не подозревает, что под капотом - рекурсивный обход XML-матрёшек, пиксельная арифметика VML-боксов и принудительная сборка мусора каждые 500 итераций.

А процессор, как мы помним, всё так же тупо и быстро перекладывает нули и единицы. Просто теперь он делает это во имя бухгалтерии.

Огромное спасибо всем тем кто дочитал статью до этого момента, простите что получилось так много, я старался рассказать всё. Этот проект мне очень понравился своей непредсказуемостью и отсутствием каких либо решений в сети, по этому я делюсь с вами многими моментами которые пощекотали мои нервишки. Надеюсь вам понравился изложенный опыт.

P.S. Немного о цифрах. Писал статью я на протяжении всей разработки... писал, переписывал и так ровно 21 день, что бы поделиться интересным кейсом. Начал писать с 32 зубами, а закончил с 31)) Здесь долгая история...

P.P.S. Нет, я не буду выкладывать полный исходный код. Но если будет интерес - могу написать отдельную статью про конкретную ветку (DOCX/PDF/RTF) с более детальными примерами. Пишите, что интереснее.

Комментарии (8)


  1. ip-Voronin
    21.04.2026 06:21

    Прямо ЦБшный УФЭБС не могут отдавать? Каждый придумывает свой велосипед?


    1. MrSotnik Автор
      21.04.2026 06:21

      Такой прикол документооборота, получил бумажку сначала сам, а потом передал 1С. Я так же задавался вопросом почему та))


      1. ip-Voronin
        21.04.2026 06:21

        Я предполагаю, что это, скорее всего, недоработки коммуникации менеджмента/бухгалтерии заказчика и кредитных организаций.

        Очень странно для кредитной организации не реализовывать УФЭБС в своём клиент-банке, если они и так обязаны общаться (наверное, всё ещё в КБР-Н) посредством оного с подразделениями ЦБ.


        1. MrSotnik Автор
          21.04.2026 06:21

          Всё верно, согласен с вами, но зато спасибо им за крутой кейс))


  1. aborouhin
    21.04.2026 06:21

    Ох, знакомо. Скажите спасибо, что Вам ещё бинарный .doc не достался, но советую на всякий случай готовиться :)

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


  1. pvzh
    21.04.2026 06:21

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


  1. denisgrigoriev04
    21.04.2026 06:21

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


    1. MrSotnik Автор
      21.04.2026 06:21

      По этому и разрабатывался, алгоритм который может и при смене структуры всё правильно разложить (ведь он же работает на разных банках, а делался по сути для 3-х а сейчас на нём крутят 10), и плюс ко всему эти платёжки не менялись 10 лет, вряд ли за следующие 10 что-то с ними изменится, ведь банки во многих отношениях староверы.