Это первая статья из цикла о построении 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 — в комментариях. Особенно интересно, какие банки у вас и какие форматы скриншотов.
Комментарии (3)

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