Rank tracker
Разработка архитектуры и MVP SaaS-сервиса с нуля
Роль
Fullstack Developer / Software Architect (Solo)
Год
2026
Стек
NestJS 11Next.js 16 (React 19)Prisma 7PostgreSQL 16Redis 8BullMQTurborepoDockerDokploy
Задача
Задача
Продуктовая цель:
Разработать с нуля полноценный SaaS-сервис для SEO-специалистов и владельцев бизнеса, позволяющий автоматизировать мониторинг позиций сайтов в Яндексе и Google. Сервис должен избавить пользователей от ручных проверок: достаточно загрузить список ключевых фраз (с учетом региона и типа устройства), после чего система будет по расписанию собирать данные и наглядно показывать динамику ранжирования (рост, падения, попадание в Топ-3) в формате интерактивной пивот-таблицы.
Инженерный вызов:
В рамках pet-проекта стояла задача спроектировать не просто MVP, а production-ready архитектуру, готовую к реальным нагрузкам и масштабированию. Необходимо было:
- Построить отказоустойчивый асинхронный конвейер сбора данных (SERP), способный обходить ограничения и квирки внешних API.
- Исключить состояние гонки (race conditions) при расчете пользовательских квот (FREE/PAID тарифы) между планировщиком и ручными запусками.
- Обеспечить сквозную типобезопасность (End-to-End Type Safety) между независимыми приложениями фронтенда и бэкенда без дублирования кода.
- Настроить строгий многоуровневый rate-limiting для защиты как собственного сервера от абьюза, так и платного внешнего API от перерасхода запросов.
Этапы
Проектирование и архитектура (Spec-first)
- Работа началась с составления дизайн-документов (Scope, Goals, Non-Goals) для каждого узла системы.
- Для обеспечения единого источника правды (Single Source of Truth) был развернут монорепозиторий на базе Turborepo.
- Схемы валидации написаны на Zod и вынесены в общий пакет
@rank-tracker/shared, что гарантировало 100% синхронизацию API-контрактов между бэкендом (NestJS) и фронтендом (Next.js).
Безопасность, Auth и работа с данными
- Настроена база данных PostgreSQL с ORM Prisma (схема прошла через 11 итераций/миграций).
- Реализована stateless-аутентификация: JWT-токены хранятся исключительно в
httpOnlycookies. - Для бесшовного пользовательского опыта (UX) на стороне Next.js настроен BFF-прокси, который перехватывает ошибки 401 и делает «тихий» (silent) refresh токенов без перезагрузки страницы.
Разработка ядра (Асинхронный SERP-конвейер)
- Спроектирован механизм сбора позиций.
- Запущены воркеры на BullMQ. Для защиты от race conditions при списании квот между ночным кроном и ручными проверками были написаны атомарные Lua-скрипты для Redis.
- Реализован парсер XML-ответов от поисковиков со встроенным механизмом экспоненциальных ретраев для обхода специфичных ошибок API (например, «код 110»).
Клиентский интерфейс (Frontend & UX)
- На базе React 19, Tailwind CSS 4 и компонентов shadcn UI разработан адаптивный дашборд.
- Главный элемент — сложная интерактивная пивот-таблица, визуализирующая историю позиций.
- Добавлен отказоустойчивый модуль импорта ключей из CSV/XLSX с автоматическим детектированием и исправлением битой кодировки (win1251).
Инфраструктура и CI/CD
- Приложения упакованы в легковесные Docker-контейнеры (multi-stage сборка).
- Настроен автоматизированный пайплайн в GitHub Actions: прогон линтеров, запуск юнит-тестов парсера на реальных XML-фикстурах, сборка образов, отправка в GHCR и автоматический деплой на продакшен-сервер через Dokploy.
Процесс
Rank Tracker — SaaS-трекер позиций сайта в Яндексе и Google
Pet-проект: полноценный SaaS-сервис мониторинга позиций сайта в поисковой выдаче — от идеи и спецификаций до CI/CD и продакшен-деплоя.
Стек одной строкой: TypeScript · NestJS 11 · Prisma 7 · PostgreSQL 16 · Redis 8 · BullMQ · Next.js 16 (App Router, React 19) · Tailwind CSS 4 · shadcn/Base UI · TanStack Query 5 · Zod 4 · Turborepo · Docker · GitHub Actions → GHCR · Dokploy
Идея
SEO-специалисту и владельцу сайта нужно знать, на каких позициях его сайт находится в поиске по важным запросам — и как эти позиции меняются со временем. Rank Tracker решает эту задачу:
- пользователь создаёт проект (домен сайта) и добавляет ключевые фразы — с указанием поисковой системы (Яндекс / Google), региона и типа устройства (desktop / mobile / tablet);
- система по расписанию (и по ручному запуску) опрашивает поисковую выдачу через SERP-API (xmlstock.com), находит позицию домена и сохраняет её в историю;
- дашборд показывает пивот-таблицу истории позиций (даты — колонками, свежие слева), где попадание в топ-3 выделено золотым, рост — зелёным, падение — красным, плюс сводные карточки: всего ключей, средняя позиция, количество в топ-3.
Проект спроектирован как SaaS: тарифы FREE / PAID, дневные квоты проверок (FREE — 20/день), защита от абьюза, админ-инструменты для управления тарифами.
Возможности
- Аутентификация: регистрация с подтверждением email, вход по JWT (access 15 мин + refresh 30 дней с ротацией) в httpOnly-cookies; Cloudflare Turnstile и лимит регистраций с одного IP.
- Проекты: CRUD, домен уникален в рамках пользователя, настраиваемый интервал автопроверок.
- Ключевые фразы: массовое добавление вставкой текста или импортом CSV/XLSX (до 2 МБ, автодетект «битой» кодировки win1251); каскадный выбор: поисковик → регион (отфильтрован по движку) → устройство; дедупликация на уровне БД.
- Проверки позиций: ежедневный cron + ручной запуск (всех ключей или выборочно); Яндекс — топ-100, Google — топ-50; квоты на пользователя в день.
- Дашборд: пивот-таблица истории за 30 дней с цветовой индикацией трендов, карточки статистики, переключатель Яндекс/Google (состояние в URL).
Архитектура
Монорепозиторий pnpm + Turborepo из трёх воркспейсов: apps/api (NestJS), apps/web (Next.js) и packages/shared — пакет Zod-схем, который делает контракты API едиными для фронта и бэка.
Пайплайн проверки позиций
Ядро проекта — асинхронный конвейер сбора позиций на BullMQ:
Пошагово:
- Триггер — ежедневный cron или ручной запуск из UI.
- Отбор — планировщик ставит в очередь только ключи, чья последняя проверка старше
checkIntervalпроекта. - Квота — атомарный Lua-скрипт в Redis резервирует слоты под дневным лимитом пользователя. Два режима:
reserveExact(всё-или-ничего для ручных проверок, иначе 429 с деталямиused/limit) иreserveUpTo(best-effort для ночного cron — берёт сколько осталось). - Очередь — задания с 3 попытками и экспоненциальным backoff; ретраится только то, что помечено как retryable.
- Fetch — общий token-bucket на 20 req/s к платному API; Яндекс отдаёт топ-100 одним запросом благодаря deep grouping, Google ограничен 10 результатами на страницу — сервис обходит до 5 страниц с ранним выходом и обрабатывает особенность xmlstock: код 110 («запрос поставлен в очередь») → повтор того же запроса до 4 раз с паузой 7 с.
- Парсинг и сохранение — защитный парсинг XML без
any, нормализация домена (протокол/www/порт/путь), записьPositionCheck(null= домена нет в топе).
Модель данных
Технологический стек
| Слой | Технологии |
|---|---|
| Backend | NestJS 11, Prisma 7 (@prisma/adapter-pg, multi-file схема), PostgreSQL 16, Redis 8 + ioredis, BullMQ, passport-jwt, argon2, nodemailer, fast-xml-parser, Joi (env) + Zod/nestjs-zod (DTO) |
| Frontend | Next.js 16 (App Router, RSC, standalone), React 19, TypeScript, Tailwind CSS 4, shadcn на Base UI, TanStack Query 5, Cloudflare Turnstile |
| Shared | @rank-tracker/shared — Zod 4 схемы и контракты API (единый источник правды для обоих приложений) |
| Импорт | csv-parse, SheetJS (xlsx), iconv-lite |
| Инфра | Turborepo, Docker multi-stage (turbo prune --docker, node:22-alpine, non-root), docker-compose (Postgres + Redis с ACL), GitHub Actions → GHCR, деплой на Dokploy |
| Тесты | Jest 30, юнит-тесты SERP-парсера на реальных XML-фикстурах, e2e через supertest |
Инженерные решения
Атомарные квоты на Redis Lua. Ручная проверка и ночной cron могут конкурировать за дневной лимит одного пользователя. Вместо GET+SET с гонкой — Lua-скрипт через EVAL: проверка и резервация происходят атомарно, ключ привязан к дате в таймзоне пользователя сервиса (TTL 25 ч). Перерасход лимита невозможен в принципе.
Единые Zod-контракты вместо tRPC. Одна схема в packages/shared порождает: валидацию запросов в NestJS (createZodDto), типы TypeScript и клиентскую валидацию форм. Фронт и бэк физически не могут разъехаться по формату данных — при этом API остаётся обычным REST, доступным любому клиенту.
BFF-прокси с прозрачным refresh. Токены живут только в httpOnly-cookies — JS их не видит. Route handlers Next.js проксируют запросы к NestJS; при 401 прокси сам вызывает /auth/refresh, подмешивает обновлённые set-cookie в повтор запроса и пробрасывает их браузеру. Пользователь не замечает истечения access-токена.
Двухдвижковый SERP-клиент с обработкой квирков. У Яндекса и Google через xmlstock разная механика: Яндекс отдаёт топ-100 одним запросом (deep grouping), Google — максимум 10 результатов на страницу, поэтому реализован постраничный обход с ранним выходом и ретраями фирменного «кода 110». Ошибки типизированы (SerpFetchError с флагом isRetryable) — политика ретраев очереди опирается на природу ошибки, а не ретраит всё подряд.
Rate limiting на трёх уровнях, каждый на своём месте. Per-IP лимит регистраций (Redis, 3/день — считает и неудачные попытки, чтобы душить перебор капчи), limiter воркера BullMQ (100 jobs/s), in-process token-bucket к платному SERP-API (20 req/s по лимитам Yandex.XML).
Импорт, который переживает реальные файлы. CSV из русского Excel часто приходит в win1251: парсер детектит битую декодировку по символам U+FFFD и перечитывает файл через iconv-lite; поддерживаются разделители ;/, и XLSX. Результат парсинга проходит через ту же Zod-схему, что и ручной ввод.
Безопасность по умолчанию. argon2id для паролей, ротация refresh-токенов, каждый запрос к данным скоупится по userId на уровне сервисов (WHERE {id, userId} — IDOR исключён архитектурно), Turnstile на регистрации, в Redis отключён default-пользователь и создан ACL-пользователь с ограниченными правами.
Проверяемость пайплайна. Юнит-тесты парсера гоняются на реальных записанных XML-ответах обоих поисковиков; для каждого шага конвейера есть smoke-скрипт (test-xmlstock, test-queue, test-processor); админ-CLI (set-user-plan) включён в прод-образ и запускается через tsx прямо в контейнере.
Процесс разработки
- Spec-first: каждый инкремент пайплайна (клиент xmlstock → Redis compose → очередь → процессор → HTTP-триггер) начинался с дизайн-документа с явными Scope / Goals / Non-Goals (
docs/superpowers/specs/). - Эволюция схемы БД — 11 Prisma-миграций: от init до отказа от серверных сессий в пользу stateless JWT, каскадных удалений, анти-абьюз полей и переноса поисковой системы с проекта на регион (поддержка Google в тех же проектах).
- CI/CD: gate из lint + тестов → матричная сборка Docker-образов API и Web → push в GHCR с кэшированием слоёв и тегами
latest/sha/tag → деплой на Dokploy. Прод-контейнер API сам применяет миграции при старте (осознанное допущение single-replica, зафиксировано в комментарии). - Feature-ветки через git worktree, слияние через PR.
Планы развития
- Абстракция
SearchProvider— подключение DataForSeo / SerpApi как альтернативных источников SERP. - Aggregation worker: видимость, динамика средней позиции, рост/падение по проекту.
- Биллинг для тарифа PAID.
- Графики истории позиций в дополнение к пивот-таблице.
- Проверка отдельных слов
- Экспорт данных в XLSX и CSV
- Анализ данных таблицы за выбранный период с генерацией краткого отчета с помощью ИИ
Результаты
concurrent-воркеров в BullMQ
независимых уровня Rate Limiting
режима резервирования квот