TL;DR: Я хотел просто сделать JSON с хадисами. В итоге написал свой парсер для PDF, боролся с Gradle и доменами в Maven Central, ломал генерацию картинок в Satori и оптимизировал Fuzzy Search с 5 секунд до 2.7 секунд с помощью AI. Это история о том, как пет-проект превратился в экосистемо на NestJS, Prisma и Redis.
Введение: "Всё должно было быть просто"
Идея казалась элементарной: сделать удобное API для хадисов — изречений Пророка Мухаммада (мир ему). Казалось бы, 2026 год на дворе, всё уже должно быть оцифровано, лежать на GitHub в красивом JSON, бери и пользуйся.
Я начал гуглить. Реальность ударила сразу:
Большинство данных — это дампы SQL 2010 года в кодировке cp1251.
Существующие API падают, если отправить 10 запросов подряд.
Тексты с ошибками: арабский без огласовок, перепутанные номера, "битые" символы.
На GitHub лежит пара репозиториев, но последний коммит там датируется 2018 годом, а README гласит «работает на моей машине».
Я понял: если хочешь сделать хорошо — придется делать всё с нуля. Парсить, чистить, архитектурить. Так родился проект, который высосал из меня неделю жизни только на этапе парсинга одной книги.
Цифры проекта
Стек: NestJS → Prisma → PostgreSQL + Redis. Всё в Docker, живёт на Railway.

Параметр | Значение |
|---|---|
📚 Коллекций | 6 (Бухари, Муслим, Тирмизи, Абу Дауд, Насаи, Ибн Маджа) |
📖 Хадисов | ~15 000+ (база постоянно пополняется) |
🌍 Языков | 3 (Арабский, Английский, Русский) |
⚡ RPS | ~400 req/sec на одном инстансе (спасибо Fastify/NestJS) |
💾 Размер БД | ~2 GB (из-за "жирных" GIN-индексов) |
Боль №1: Данные и тот самый "Муслим"
Самая большая проблема исламских данных — отсутствие стандартов. У каждого сайта своя вёрстка. Скрипты, которые я писал, часто скачивали не все хадисы или ломались на середине. Сначала я радовался: «О, тут есть API!» — но оказывалось, что он возвращает данные без арабского текста. Или с арабским, но без огласовок. Или с огласовками, но перепутанными номерами.
Но настоящим адом стали PDF. Я нашёл сканы оригинальных книг. «Отлично», — подумал я, — «там точный текст». Проблема: при конвертации PDF → Text сноски (футеры с тафсирами) вклеивались прямо в середину предложений.
Представьте: идёт хадис о молитве, и вдруг посреди текста: «25. См. Шарх Сахих Муслим, т. 4, с. 12». Книжная сноска, которая физически находилась внизу страницы, при парсинге оказывалась в середине хадиса. Парсер pdf2text просто читает символы слева направо, сверху вниз — и не понимает, что сноска в другой колонке.

Битва с регулярками
Поначалу я думал: «Просто уберу цифры в верхнем индексе». Хаха. Три часа спустя:
python # Failed attempt 1: убило номера самих хадисов! re.sub(r'\d+', '', text) # Failed attempt 2: помогло с номерами страниц, # но не со сносками внутри строк re.sub(r'^\d+\s*$', '', text)
В итоге пришлось использовать unicode-диапазоны для superscript символов и flags=re.DOTALL, чтобы вырезать пояснения, которые могли занимать несколько строк:
Даже с этими регулярками — не панацея. Иногда парсер выдаёт список "дыр" в ID (хадис 145, потом сразу 148), и я иду в PDF глазами искать, где он споткнулся об очередную таблицу или сложную вставку.
Честно признаюсь: Сахих Муслим я парсю до сих пор. Это 7500+ хадисов, и там самые кривые PDF из всей коллекции. Бесконечная битва за чистоту данных.
Архитектура: Монолит, который смог
Я выбрал NestJS. Почему не Express? Потому что после хаоса с данными мне хотелось строгости. TypeScript, Dependency Injection, Декораторы — это как Spring Boot, только без JVM overhead. Всё по месту, всё типизировано.
Схема БД (Prisma): Типизация > Всё

Схема базы данных стала фундаментом всего. У хадиса сложные связи: он принадлежит коллекции и книге, у него может быть несколько переводов, у каждого перевода — свой grading (оценка достоверности).
model Hadith { id Int @id @default(autoincrement()) collectionId Int? @map("collection_id") bookNumber Int @map("book_number") hadithNumber Int @map("hadith_number") arabicText String @map("arabic_text") createdAt DateTime @default(now()) @map("created_at") collectionRef Collection? @relation(fields: [collectionId], references: [id]) translations Translation[] @@unique([collectionId, bookNumber, hadithNumber]) @@index([collectionId]) @@index([bookNumber]) } model Translation { id Int @id @default(autoincrement()) hadithId Int @map("hadith_id") languageCode String @map("language_code") text String grade String? hadith Hadith @relation(fields: [hadithId], references: [id], onDelete: Cascade) }
Ключевые решения в схеме:
@@unique([collectionId, bookNumber, hadithNumber])— гарантирует уникальность. Без этого при повторном запуске парсера можно получить дубликаты.onDelete: Cascade— если удаляю "кривой" хадис при исправлении ошибок, все его переводы удаляются автоматически. Без этого база быстро заполняется "сиротами".Translation отдельной таблицей — позволяет добавлять языки без изменения основной структуры.
Про Prisma Migrate в продакшне: всегда делайте бэкапы перед миграцией. Я один раз запустил
migrate deployбез бэкапа — всё прошло хорошо, но адреналин был неоправданным.
Техническое "мясо": Поиск и Оптимизация
Проблема: 5 секунд на поиск
Поиск казался простым: ILIKE %query%. Работало мгновенно. Но пользователи пишут с опечатками. "Пророк" превращается в "Прарок", "Muhammad" в "Muhammed". Для религиозного контента это критично — человек не найдёт нужный хадис из-за одной буквы.
Я включил pg_trgm (расширение PostgreSQL для нечёткого поиска). Расширение разбивает текст на триграммы — тройки символов — и сравнивает их. "молитва" → "мол", "оли", "лит", "итв", "тва".
Проблема: без индексов Postgres сканировал всю таблицу Translation (15к строк × средний текст ~300 слов). Время ответа — 5 секунд. Для API это вечность.

Решение: GIN-индексы и динамический порог
Я не гуру DBA. Попросил помощи у Claude (Opus), и мы вместе переписали запрос. Ключевые решения:
GIN vs GiST: GIN быстрее на поиск, хоть и медленнее на вставку. Хадисы добавляются раз в месяц, ищут каждую секунду — выбор очевиден.
Динамический порог схожести: для коротких слов строже, для длинных — мягче.
Комбинированный поиск: одновременно по переводу и арабскому оригиналу.
// hadiths.service.ts private calculateSimilarityThreshold(query: string): number { const length = query.length; // Для коротких слов (5 букв) нужна высокая точность, // иначе "мир" найдёт "кумир", "эмир" и т.д. if (length <= 5) return 0.7; // Для длинных фраз можно прощать опечатки if (length > 10) return 0.3; return 0.4; }

А вот сам SQL-запрос, который стал "виновником" быстрого поиска:
-- Устанавливаем порог схожести для текущей транзакции SET pg_trgm.word_similarity_threshold = 0.4; WITH matching_translations AS ( SELECT hadith_id, word_similarity('prayer', text) as score FROM translations WHERE language_code = 'en' AND (text ILIKE '%prayer%' OR 'prayer' <% text) ), matching_arabic AS ( SELECT id as hadith_id, word_similarity('prayer', arabic_text) as score FROM hadiths WHERE (arabic_text ILIKE '%prayer%' OR 'prayer' <% arabic_text) ), combined_matches AS ( SELECT hadith_id, score FROM matching_translations UNION ALL SELECT hadith_id, score FROM matching_arabic ), best_scores AS ( SELECT hadith_id, MAX(score) as relevance FROM combined_matches GROUP BY hadith_id ) SELECT h.*, b.relevance FROM best_scores b INNER JOIN hadiths h ON b.hadith_id = h.id ORDER BY b.relevance DESC LIMIT 20;
Результат: сложные поисковые запросы (например, «рророк» с опечаткой, который встречается в тысячах хадисов) стали отрабатывать за ~2.7 секунд вместо 5. При этом «обычные» запросы по более редким словам теперь занимают меньше секунды. Это всё ещё не мгновенно, но уже гораздо лучше для нечёткого поиска по большому объёму неструктурированного текста на скромном железе.
UPD: Большой апдейт после публикации 🚀
После выхода статьи продолжил активно развивать проект. Вот что изменилось:
UPD 1: Mumin Shield — из MVP в enterprise-grade 🛡️
Изначально Shield был простым: считал запросы и блокировал по порогу. Но у него была слепая зона — продвинутый скрапер, который рандомизирует порядок запросов, легко проходил мимо.
Что изменилось:
Переезд с PostgreSQL на Redis Sorted Sets. Поведенческий анализ в реальном времени требует sub-millisecond отклика. Записывать каждый запрос в Postgres и тут же читать было дорого. Теперь Redis ведёт скользящие окна — вставка O(log N), поиск по диапазону O(log N + K):
// Добавляем запрос в скользящее окно (60 секунд) const windowKey = shield:content:${apiKeyId}; const now = Date.now(); await redis.zadd(windowKey, now, ${hadithId}:${now}); await redis.zremrangebyscore(windowKey, 0, now - 60_000); await redis.expire(windowKey, 120); // Считаем УНИКАЛЬНЫЕ хадисы — это и есть "Content Density" const members = await redis.zrange(windowKey, 0, -1); const uniqueIds = new Set(members.map((m) => m.split(":")[0]));
«Content Density» вместо счётчика запросов. Вместо «сколько раз стучались» теперь «сколько уникальных хадисов запросили за 60 секунд». Реальный пользователь читает одну страницу — 1–3 хадиса. Скрапер за то же время берёт 50+ уникальных. Разница принципиальная.
IP-слой поверх API-ключевого. Добавил второй уровень — анализ по IP независимо от ключа. Это закрывает схему «купил 10 аккаунтов, ротирую ключи»: поведение всё равно идёт с одного IP.
Атомарные обновления баланса. Заменил user.balance - cost на Prisma decrement:
// Было: race condition при конкурентных запросах await prisma.user.update({ data: { balance: user.balance - 1 } }); // Стало: атомарная операция на уровне БД await prisma.user.update({ data: { balance: { decrement: 1 } } });
UPD 2: Поиск 5с → 2.7с → 1.1с ⚡
Три итерации оптимизации поиска:
Итерация 1 (была в статье): GIN-индексы + динамический порог схожести → 5с → 2.7с
Итерация 2: Оказалось, что после каждого деплоя на Railway индексы слетали — Prisma их не знала. Решение — вынести в отдельную migration.sql:
CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX IF NOT EXISTS "translations_text_trgm_idx" ON "translations" USING gin ("text" gin_trgm_ops); CREATE INDEX IF NOT EXISTS "hadiths_arabic_text_trgm_idx" ON "hadiths" USING gin ("arabic_text" gin_trgm_ops);
IF NOT EXISTS — ключевое. Миграция идемпотентна, деплой safe.
Итерация 3: Redis-кэш результатов + нормализация запроса. Cache hit rate вырос с ~30% до ~65%, потому что "Молитва ", "молитва" и "МОЛИТВА" теперь один ключ:
const normalized = query.trim().toLowerCase().replace(/\s+/g, " "); const key = search:${lang}:${normalized}:${page}; const cached = await redis.get(key); if (cached) return JSON.parse(cached); // ... бьём в базу только при cache miss ... await redis.setex(key, 300, JSON.stringify(result));
Итог: 5с → 2.7с → 1.1с 🎯
Важный нюанс про арабский: для него индексы работают хуже из-за морфологии языка — одно слово имеет десятки форм. Это следующая задача в роадмапе.
Redis: Кэширование и Rate Limiting
Hadith of the Day
Самый популярный эндпоинт — "хадис дня". Дёргать базу при каждом запросе на главную страницу было бы расточительно:
// hadiths.service.ts async findDaily() { const cacheKey = hotd:${new Date().toISOString().split('T')[0]}; const cached = await this.redis.get(cacheKey); if (cached) return JSON.parse(cached); const hadith = await this.dbFindRandom(); // TTL 24 часа — хадис меняется ровно в полночь await this.redis.setex(cacheKey, 86400, JSON.stringify(hadith)); return hadith; }
Rate Limiting
Защита от спама по IP — стандартная схема с инкрементом счётчика:
const key = ratelimit:${ip}; const count = await redis.incr(key); if (count === 1) await redis.expire(key, 60); // Окно 60 секунд if (count > 100) throw new TooManyRequestsException();
Для личного использования лимиты отключены. Для публичного API — 100 запросов в минуту на ключ.
Красивый шеринг: Satori и баг с Auth
Хотелось, чтобы когда кто-то шарит хадис в Telegram или Twitter, превью выглядело красиво. Я решил генерировать OG-картинки на лету с текстом хадиса. Использовал @vercel/og (внутри — библиотека Satori).
Концепция простая: пользователь открывает /hadiths/bukhari/1/image → Edge Function генерирует SVG с текстом → конвертирует в PNG → отдаёт. Без хранения на диске, без S3.
И тут я поймал два интересных бага.
Баг №1: 401 в Edge Runtime
Генератор картинок работает в Edge-Runtime на Vercel. Когда он пытался зафетчить текст хадиса из моего API, он получал 401 Unauthorized.
Почему? В Middleware я настроил проверку API-ключа. ��о Edge Function — это не браузер пользователя, у неё нет сессии, нет заголовков. Пришлось завести отдельный "сервисный" ключ для внутренних вызовов и прописать его в окружении Vercel Edge через переменные окружения.
Баг №2: Арабские квадраты
Если вы используете Satori с нестандартными языками — арабским, хинди, — вам нужно подгружать шрифт прямо в буфер. Иначе вместо красивых арабских букв будут ▯▯▯▯.
const fontData = await fetch( new URL('../fonts/NotoNaskhArabic-Regular.ttf', import.meta.url) ).then(res => res.arrayBuffer()); const svg = await satori(<HadithCard text={hadith.arabicText} />, { fonts: [{ name: 'NotoNaskhArabic', data: fontData }] });
Kotlin SDK: Для Android-разработчиков
Я хотел, чтобы API было удобно использовать с Android. Написал SDK — обёртку над Retrofit + OkHttp + Moshi.
Главная фишка — AuthInterceptor, который автоматически добавляет API-ключ к каждому запросу:
class AuthInterceptor(private val apiKey: String) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request().newBuilder() .addHeader("X-API-Key", apiKey) .build() return chain.proceed(request) } } // Использование: val client = MuminClient(apiKey = "your_key") val hadith = client.getHadith(collection = "bukhari", number = 1) println(hadith.translations.first { it.lang == "ru" }.text)
Все ответы обёрнуты в BaseResponse<T>, который парсит метаданные (пагинацию, время ответа) и выбрасывает кастомные исключения при невалидном ключе или превышении лимита.
Боль №2: Gradle и Maven Central
Когда SDK был готов, я захотел опубликовать его в Maven Central. Казалось бы — одна команда: ./gradlew publish. Ха!
GPG ключи: Gradle упорно не видел мой секретный ключ, пока я не прописал его в
gradle.propertiesв Base64-формате. Сообщение об ошибке при этом было максимально бесполезным.Старый портал vs новый: Я потратил 3 часа, пытаясь залогиниться в OSSRH (старый портал Sonatype), пока не понял, что новые проекты теперь регистрируются только через
central.sonatype.com. Логи выдавали сотню ошибок 401.Верификация домена: Для получения groupId в стиле
ink.muminнужно подтвердить владение доменом через TXT-запись в DNS. Звучит просто, но в сочетании с предыдущими двумя пунктами это заняло целый день.
publishing { publications { create<MavenPublication>("release") { groupId = "com.mumin" artifactId = "hadith-sdk" version = "1.0.0" // Обязательно: лицензии, developers, SCM — // без этого Sonatype не пропустит библиотеку pom { name.set("Mumin Hadith SDK") description.set("Kotlin SDK for mumin.ink API") url.set("https://github.com/your/repo") licenses { license { name.set("MIT") } } developers { developer { name.set("Your Name") } } } } } }
## Деплой и CI/CD: Путь в облака
Проект живёт на двух платформах:
Backend (Railway): Выбрал за отличную поддержку Docker и встроенный PostgreSQL с Redis. Авто-деплой по пушу в
main. Критичный нюанс: нужно прописатьnpx prisma migrate deployв start-команду, иначе схема базы отстаёт от кода.Frontend & Docs (Vercel): Next.js + Nextra для документации. Деплой автоматический, CDN из коробки.
CI/CD (GitHub Actions): Прогоняет тесты и линтеры на каждый PR. Самое сложное — настроить кэширование
node_modules, чтобы пайплайн не занимал 10 минут.Мониторинг пока "дедовский" — читаю логи в Railway консоли. OpenTelemetry в роадмапе.
The Elephant in the Room: Я и AI
Этот проект выглядит как работа команды из трёх человек. Но я один. Мой секрет — AI (преимущественно Claude).
Я использовал его не как "кодописку", а как Senior-напарника. Вот реальные запросы из истории:
"Как лучше спроектировать схему для переводов хадисов?"
"Оптимизируй этот SQL, у меня 5 секунд latency на триграммном поиске"
"Объясни разницу GIN vs GiST для pg_trgm"
Кейс: AI vs Security
Был показательный момент: я попросил Claude помочь с сохранением сессии в Dashboard. AI выдал стандартный ответ: «Возьми
js-cookie, сохрани токен на клиенте, и всё будет летать».Для новичка это звучит логично. Но я понимал: хранить Auth-токен в доступном для JavaScript месте — это подарок для любой XSS-атаки. Я остановил Claude и настоял на
httpOnlyкуках, которые устанавливаются бэкендом и недоступны для скриптов на фронте.Мы вместе переписали логику: NestJS ставит
httpOnly CookieсSameSite: Strict, Next.js Middleware читает её через server-side. Дольше, сложнее, но безопасно.Вывод: AI может написать хороший код, но он не понимает контекст вашего проекта. Он не знает, что ваши PDF кривые, что ваши пользователи на мобильных, и что
httpOnlycookie важнее, чем "просто быстро заработает". Код писал он, архитектором и ревьюером был я.Итоги: Чему я научился за 3 месяца
Данные — это 80% работы. Красивый бэкенд бесполезен, если в базе мусор. Не недооценивайте этап подготовки данных.
PDF — это зло. Если есть возможность парсить HTML — парсите HTML. PDF оставьте на крайний случай.
GIN-индексы жрут место. База выросла с 500 МБ до 2 ГБ только из-за триграммных индексов. Это нормально — место дешевле, чем 5 секунд latency.
Не оптимизируйте раньше времени. Я сначала сделал
ILIKE, и только когда прижало — перешёл на триграммы. Это как по мне правильно.AI не заменит понимание домена. Он напишет код, но не поймёт, что такое тафсир в сноске, зачем
SameSite: Strict, или почему арабский требует специального шрифта.Pet-проекты должны болеть. Только через боль от Gradle или кривых PDF приходит настоящий опыт.
Что пока не идеально (Roadmap)
⏳ Сахих Муслим всё ещё парсится — там самые кривые PDF из коллекции.
🐌 Поиск по арабскому медленнее — нужно отдельно тюнить индексы для арабской морфологии.
🔄 Нет версионирования данных — если нахожу ошибку в тексте, правлю вручную в базе.
📊 Мониторинг — хочу OpenTelemetry вместо "смотрю логи в Railway".
Попробуйте сами
API уже работает в проде. Получите бесплатный ключ в дашборде и попробуйте:
# Получить хадис по коллекции и номеру (Сахих Бухари, #1) curl -H "X-API-Key: ваш_ключ" "https://api.mumin.ink/v1/hadiths?collection=bukhari&hadithNumber=4712" # Нечёткий поиск по тексту curl -H "X-API-Key: ваш_ключ" "https://api.mumin.ink/v1/hadiths/search?q=молитва&language=ru" # Хадис дня curl -H "X-API-Key: ваш_ключ" https://api.mumin.ink/v1/hadiths/dailyДокументация: https://docs.mumin.ink
Это Open Source, бесплатно для личного использования и без rate limits для разработки. Если вы пишете мусульманское приложение или просто хотите поэкспериментировать с Arabic NLP — буду рад коллаборации. Ссылка на GitHub в документации.
Мне 16, это мой первый серьезный проект, буду рад любому фидбеку
