Это первая статья из цикла о построении 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 в `` |
«КОНТЕКСТ ВРЕМЕНИ» |
Без даты файла 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 |
Статистика |
Обработка фото
При получении скриншота бот:
Скачивает фото максимального разрешения
Отправляет в Claude Vision
Парсит JSON-ответ
Проверяет на дубликаты
Сохраняет в 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 — в комментариях. Особенно интересно, какие банки у вас и какие форматы скриншотов.
Комментарии (17)

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

PaulNoks Автор
17.01.2026 11:22Согласен, для чистого учёта расходов — проще. Но цель цикла статей — построить CDC-пайплайн с нуля и разобраться как он работает. Скриншоты дают разнообразные события: INSERT при парсинге, UPDATE при верификации, DELETE при удалении мусора. Выписка дала бы только INSERT'ы

proDan0
17.01.2026 11:22Там проблема в том что многие банки скриншоты блочат на уровне системы, так что решение не универсальное. У себя (андроид) через АДБ стримлю экран и делаю скриншоты, которые потом загоняю в нейросеть. Стоило бы автоматизировать, но мотивации пока не придумал ;)

Tizar1121
17.01.2026 11:22Как сделано у меня: Пуш-уведомления об операциях от банка -> парсинг в Macrodroid -> расширение для гугл таблиц Apps Script -> занесение в файл в гугл таблицы.

PaulNoks Автор
17.01.2026 11:22Рабочее решение. У меня была похожая идея, потом реализовал с сохранением в excel, но тут хотелось именно PostgreSQL → Debezium → Kafka — чтобы разобраться в стеке, который использую на работе

TolK84
17.01.2026 11:22Незнаю как сейчас в РФ, но в Казахстане скрины банк аппов не сделать без особых выкрутасов.

riky
17.01.2026 11:22Тоже помню раньше такое было, сейчас попробовал у тбанка скриншоты запрещены только на странице где реквизиты карты, причем только после нажатия "показать реквизиты". Остальное все скриншотится. Я думаю юзеры часто скидывают скрин после оплаты.

TolK84
17.01.2026 11:22Да раньше также выборочно было, теперь любая страница черный квадрат малевича. И отключить запрет нельзя.

Usurer
17.01.2026 11:22Мне кажется, что без разбивки на категории такой учёт не очень полезен, в смысле, что в той или иной форме он уже есть в банковском приложении. А к какой категории относится чек на 123 рубля от ООО Ромашка знает только пользователь. Ну или покупки на маркетплейсе, где всегда одна и та же организация в чеке. В итоге всё равно надо вспоминать и руками прописывать категорию ко многим транзакциям. Я понимаю, что задача решается во многом учебная, но тем не менее.

babysas
17.01.2026 11:22Категории, вообще самая непростая штука. Если пытаться из этого пользу извлекать.
т. к. многие покупки идут "одним чеком" и бнз разбивки теряют много ценности.
я когда-то начал вести учёт, чтобы понимать диапазон расходов с расбивкой на категории т.к. именно по отдельным категориям можно легко ужиматься
например в кафе и рестораны или вовсе перестать или запланированно сократить. а наример алкоголь (пиво/вино) часто в чеках с едой, и тоже очевидно его можно пустить "под нож" в случае необходимости ;) ( не все меня поймут) и опять де хорошо посматривать на тенденции в этой категории. бытовые всякие порошки/мыло и т.п. тоже часто в чеке с едой.
именно такие нюансы делают бесполезными во многом отчеты от банка, трудно реализуемыми автоматизацию. все тупо упирается в личную дисциплину по ведению расходов, а когда она соблюдается, уже расчеты и "парсинг данных из записей" хоть экселькой делаются

krabdb
17.01.2026 11:22Понятно, что это pet-проект, но... почти все банки, которые стоит использовать, дают делать экспорт транз в csv прямо в аппе. К чему эти скачки со скриншотами...

shurutov
17.01.2026 11:22Telegram → PostgreSQL → Debezium → Kafka → HDFS → DWH
Это именно у вас. А в других организациях более по-другому устроено. А ещё реализация DWH у каждого своя. Мне, например, интересны общие принципы построения, соответственно, для своего проекта я беру то, чего на работе нет. Брать работу на дом - <тут должна быть картинка из "Операция Ы" про "поллитра? Вдребезги? Да я тебя!">
Хочу видеть свои расходы в нормальной аналитике
В банковском сообщении - только сумма и продаван. Т.е. только и исключительно деньги, а это синтетический учёт, если мне память по части терминологии не изменяет. По крайней мере, на тот момент, когда нас пичкали знаниями по бухгалтерии...
Лично для меня нормальная аналитика - это, помимо денег, товары, группы товаров и их (товаров) количество, т.е. к синтетическому учёту добавляется аналитический. Из банковских документов аналитику не вытащить.
bak
Есть способ намного проще - парсить SMS от банка.
osj
Есть еще проще, разрешить получать чеки в банковском приложении, там получат чеки из OFD, распарсят, разложат по категориям и в интерфейсе отобразят.
Filvs
Доброго. У меня не все операции, к примеру, выливаются в СМС. Часть идёт в пуши. При этом банков всего два. Вопрос в том - не проще ли попробовать через банковскую выписку
bak
Пуши вроде тоже можно паристь. Выписку? Единоразово проще наверное, если надо пайплайн то кажется что смс / пуши проще парсить, выписку еще же надо сгенерировать как-то, не руками же каждый раз выгружать.