Это первая статья из цикла о построении CDC-пайплайна в домашней лаборатории.
Полный путь: Telegram → PostgreSQL → Debezium → Kafka → HDFS → DWH.
Но любой пайплайн начинается с данных — и эта статья про их получение.

Проблема

Хочу видеть свои расходы в нормальной аналитике, но банки не дают API для выгрузки операций. Можно руками вбивать каждую покупку в Excel — но это путь в никуда. Можно подключить агрегаторы типа Дзен-мани — но они требуют доступ к онлайн-банку, а я параноик.

Решение: скриншоты операций → распознавание → PostgreSQL → дальше CDC pipeline.

Кто я

SRE/DevOps инженер в банке. На работе поддерживаю CDC-репликацию — 7 баз данных, сотни миллионов записей, Debezium, Kafka, PostgreSQL.

Проблема: понимаю как работает, но не всегда понимаю почему. Решение: построить весь стек с нуля в домашней лабе. Заодно решу бытовую задачу — учёт личных расходов.

Зачем я это пишу

Честно: в первую очередь это мои заметки. На работе всё уже настроено до меня. Хочу понять, как оно устроено изнутри — поэтому строю с нуля дома.

Во вторую очередь — портфолио. Если буду менять работу, эти статьи можно приложить к резюме.

И в третью — мотивация не бросить. Если хотя бы десять человек прочитают и кому-то пригодится — значит не зря потратил выходные.

Что получилось

Telegram-бот, которому отправляешь скриншот — получаешь структурированные данные в PostgreSQL:

Схема: от скриншота до DWH. Жёлтая зона — эта статья.

Реальный результат в боте:

Отправил скриншот → получил 3 транзакции в БД за секунды

Почему не OCR

Первая идея была очевидной — Tesseract OCR. Бесплатно, локально, без API.

# Код был простой

from PIL import Image

import pytesseract

def extract_text_from_image(image_path):

    img = Image.open(image_path)

    text = pytesseract.image_to_string(img, lang='rus')

    return text

Проблемы на практике:

  • UI-элементы распознаются как текст («Что показывать», «Период», «Карта»)

  • Иконки превращаются в мусор (@, ©, №)

  • Структура теряется — непонятно где одна операция, где другая

  • Нужен отдельный парсер для каждого банка

Claude Vision смотрит на картинку и понимает контекст — различает UI от данных, группирует операции, определяет категории. Из 16 распознанных транзакций 10 были полностью корректны сразу (62.5%). Остальные легко исправляются через верификацию — и это создаёт UPDATE-события для CDC, что нам и нужно.

Стек

Компонент

Зачем

python-telegram-bot

Telegram бот с диалогами для верификации

anthropic

Официальный SDK Claude API

psycopg2-binary

PostgreSQL драйвер

Pillow

Определение формата изображения

python-dotenv

Секреты в .env файле

Архитектура кода

budget-parser/

├── src/

│   ├── telegram_bot.py      # Интерфейс: принимает фото, показывает результат

│   ├── claude_parser.py     # Ядро: отправляет в Claude, получает JSON

│   ├── db_writer.py         # PostgreSQL: INSERT/UPDATE/DELETE для CDC

│   └── date_parser.py       # Утилита: русские даты → ISO формат

├── .env                     # Секреты (в .gitignore)

└── requirements.txt

Разберём ключевые модули.


Модуль 1: Парсинг русских дат

Проблема

Банки показывают даты без года: «3 октября», «15 сентября, чт». Когда мы парсим скриншот в январе и видим «15 декабря» — это какой год?

Решение

MONTHS_RU = {

    "января": 1, "февраля": 2, "марта": 3,

    "апреля": 4, "мая": 5, "июня": 6,

    "июля": 7, "августа": 8, "сентября": 9,

    "октября": 10, "ноября": 11, "декабря": 12

}

def parse_russian_date(date_str, current_year=None):

    # "2 октября, чт" → "2 октября"

    clean_str = date_str.lower().strip()

    if ',' in clean_str:

        clean_str = clean_str.split(',')[0]

    # Извлекаем число и месяц

    match = re.search(r'(\d+)\s+(\w+)', clean_str)

    day = int(match.group(1))

    month = MONTHS_RU[match.group(2)]

    # Умное определение года

    now = datetime.now()

    year = now.year

    if month > now.month:

        year -= 1  # Декабрь в январе = прошлый год

    return f"{year}-{month:02d}-{day:02d}"

Результат

Сейчас: January 2026 (месяц 1)

============================================================

3 октября            → 2025-10-03  # октябрь > январь → прошлый год

5 января             → 2026-01-05  # январь = январь → текущий год

2 октября, чт        → 2025-10-02  # день недели отбрасывается

============================================================


Модуль 2: Claude Vision API

Подготовка изображения

Claude API требует base64 + правильный media_type:

def image_to_base64(image_path):

    with open(image_path, 'rb') as file:

        return base64.b64encode(file.read()).decode('utf-8')

def get_image_media_type(image_path):

    with Image.open(image_path) as img:

        format_map = {'png': 'image/png', 'jpeg': 'image/jpeg', 'webp': 'image/webp'}

        return format_map.get(img.format.lower(), 'image/jpeg')

Промпт — самое важное

Промпт прошёл 5+ итераций. Каждая строчка — результат конкретной ошибки:

prompt = f"""

Проанализируй этот скриншот банковских операций.

КОНТЕКСТ ВРЕМЕНИ:

Скриншот сделан: {file_date.strftime('%d.%m.%Y')} ({file_date.year} год)

КРИТИЧЕСКИ ВАЖНО ДЛЯ ДАТ:

1. Если на скриншоте ЕСТЬ дата (день и месяц) — используй год из контекста

2. Если даты НЕТ совсем — верни date: null, НЕ выдумывай!

ВАЖНЫЕ ПРАВИЛА:

1. ИГНОРИРУЙ уведомления "Нашли счёт от..." — это НЕ операции

2. ИГНОРИРУЙ итоги за день (сумма рядом с датой БЕЗ описания)

3. Если есть кнопка "Оплатить" рядом — это НЕ операция

4. Переводы с именами людей: + это доход, - это расход

5. Две операции с одинаковой суммой (+/-) с одним человеком — нормально

Верни результат СТРОГО в формате JSON:

{{

  "transactions": [

    {{

      "date": "YYYY-MM-DD",

      "merchant": "название контрагента",

      "amount": число (отрицательное для расходов),

      "type": "Расход" или "Доход",

      "category": "категория или null"

    }}

  ]

}}

КРИТИЧЕСКИ ВАЖНО:

- Только валидный JSON

- Никакого markdown (``json``)

- Никакого текста до или после

"""

Почему каждое правило появилось

Правило в промпте

Какая была ошибка

«ИГНОРИРУЙ уведомления»

Claude включал «Нашли счёт от Самарские ЖКХ» как операцию

«НЕ выдумывай дату»

Claude придумывал даты для операций без даты на скриншоте

«Никакого markdown»

Иногда Claude оборачивал JSON в ``json...``

«КОНТЕКСТ ВРЕМЕНИ»

Без даты файла Claude не знал, какой сейчас год

Защита от markdown

Даже с инструкцией Claude иногда добавляет markdown:

response_text = message.content[0].text

if response_text.strip().startswith('```'):

    response_text = response_text.replace('``json', '').replace('``', '').strip()

data = json.loads(response_text)

Модуль 3: PostgreSQL и CDC

Этот модуль особенно важен — он создаёт операции, которые Debezium захватит и отправит в Kafka.

Структура таблицы

CREATE TABLE finance.parsed_transactions (

    id SERIAL PRIMARY KEY,

    transaction_date DATE,

    merchant VARCHAR(255),

    amount NUMERIC(12,2),

    transaction_type VARCHAR(20),  -- 'income' / 'expense'

    category VARCHAR(100),

    is_verified BOOLEAN DEFAULT FALSE,

    verified_at TIMESTAMP,

    original_amount NUMERIC(12,2),    -- для истории изменений

    original_merchant VARCHAR(255),   -- для истории изменений

    source_image_hash VARCHAR(64),

    parsed_at TIMESTAMP DEFAULT NOW()

);

Дедупликация

Если парсить один скриншот дважды — не должно быть дублей:

def is_duplicate(self, transaction):

    cur.execute("""

        SELECT id FROM finance.parsed_transactions

        WHERE transaction_date = %s

          AND merchant = %s

          AND amount = %s

    """, (date, merchant, amount))

    return cur.fetchone() is not None

Дубликат = совпадение по трём полям: дата + контрагент + сумма.

CDC-операции

Каждый метод создаёт операцию для Debezium:

Метод

SQL

CDC событие

insert_transaction()

INSERT

op: "c" (create)

verify_transaction()

UPDATE

op: "u" (update)

delete_transaction()

DELETE

op: "d" (delete)

Особенно интересен UPDATE — он сохраняет историю изменений:

def verify_transaction(self, tx_id, new_amount=None, new_merchant=None):

    updates = ["is_verified = TRUE", "verified_at = NOW()"]

    if new_amount is not None and float(new_amount) != float(current_amount):

        updates.append("original_amount = amount")  # Сохраняем старое

        updates.append("amount = %s")

        params.append(new_amount)

# UPDATE выполняется → Debezium захватывает before/after

Debezium захватит before и after — в Kafka будет видно, что именно изменилось. Это важно для DWH: можно строить SCD Type 2 или просто логировать историю правок.


Модуль 4: Telegram-бот

Основные команды

Команда

Что делает

/start

Приветствие и список команд

/recent

Последние транзакции

/verify

Верификация (исправление ошибок AI)

/stats

Статистика

Обработка фото

При получении скриншота бот:

  1. Скачивает фото максимального разрешения

  2. Отправляет в Claude Vision

  3. Парсит JSON-ответ

  4. Проверяет на дубликаты

  5. Сохраняет в PostgreSQL

Верификация — UPDATE для CDC

Список непроверенных транзакций

Можно подтвердить, изменить сумму/контрагента, или удалить

Когда пользователь исправляет данные через бота:

/verify → выбор транзакции → "Изменить сумму" → ввод правильной суммы

    → db.verify_transaction(tx_id, new_amount=correct_amount)

        → UPDATE в PostgreSQL

            → Debezium захватывает изменение

                → В Kafka появляется событие с before/after

Ошибки нейросети — это не баг, а фича для тестирования CDC. Каждое исправление генерирует UPDATE, каждое удаление мусора — DELETE. В следующих статьях увидим, как эти события проходят через весь пайплайн.


Грабли и выводы

1. OCR не работает для банковских скриншотов

Слишком много UI-элементов, разные форматы у разных банков. Claude Vision понимает контекст — это решает проблему.

2. Промпт требует итераций

Первая версия работала на 60%. Каждая ошибка Claude → новое правило в промпте. После 5 итераций — приемлемая точность + верификация для остального.

3. Даты без года — нужна логика

Банки не показывают год. Решение: если месяц операции > текущего месяца → прошлый год.

4. Claude иногда игнорирует инструкции

«Никакого markdown» не гарантирует отсутствие markdown. Нужна защита в коде.

5. Дедупликация обязательна

Без неё один скриншот = дублирование данных при каждом запуске.


Стоимость

Claude Sonnet 4: $3 за 1M входных токенов, $15 за 1M выходных.

Один скриншот: ~1500 токенов на изображение, ~500 токенов промпт, ~300 токенов ответ.

Итого: ~$0.01 за скриншот. За месяц при 100 операциях — около $1.

⚠️ Для читателей из России: Claude API требует VPN или VPS за рубежом. В одной из следующих статей покажу настройку WireGuard-туннеля.


Что дальше: план цикла

#

Статья

О чём

1

Эта статья

Получение данных: Telegram + Claude Vision

2

PostgreSQL для CDC

Logical replication, слоты, настройка WAL

3

Debezium + Kafka

Захват изменений, топики, коннекторы

4

HDFS + Hive

Хранение и SQL-доступ к истории

5

Consumer + скрипты проверки данных

Что должно вычитывать, корректно класть в HDFS и проверть консистентность данных

6

Мониторинг

Grafana-дашборды для всего пайплайна

Подписывайтесь, чтобы не пропустить.


Вопросы и feedback — в комментариях. Особенно интересно, какие банки у вас и какие форматы скриншотов.

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


  1. bak
    17.01.2026 11:22

    Есть способ намного проще - парсить SMS от банка.


    1. osj
      17.01.2026 11:22

      Есть еще проще, разрешить получать чеки в банковском приложении, там получат чеки из OFD, распарсят, разложат по категориям и в интерфейсе отобразят.


  1. aborouhin
    17.01.2026 11:22

    Ну ОК, API банки не дают (Open Banking в какой-то непонятной стадии внедрения и для пет-проекта точно не вариант). Но выписки-то выгружает любой банк, и уж точно проще их обрабатывать, чем скриншоты.