Как я построил лендинг и ускорил PostBot за один спринт
Иногда несколько дней разработки вмещают в себя столько всего, что потом долго не понимаешь, с чего вообще начать рассказывать. Вот именно такой получился этот спринт: лендинг AI-конференции с нуля, деплой через Dokploy, правка дресс-кода с живыми фото — и параллельно серьёзный рефакторинг Telegram-бота, который из «я трачу $2/день на мусор» превратился в нечто управляемое. Давай расскажу по порядку.
Откуда взялась задача
Мероприятие «Навстречу к AI» прошло. Техничка подвела, стартанули позже, запись пошла в брак — но содержательно всё получилось отлично: спикеры дали плотный, полезный контент, площадка «Калибр» справилась, и люди, которые были вживую, ушли явно довольными. Осталось одно незакрытое дело — сайт. Пост с итогами уже был, Telegram-канал с материалами тоже, а вот нормального лендинга, на который можно сослаться, не существовало.
Задача звучала просто: сделать сайт мероприятия по готовому посту и материалам из канала. На практике «просто» растянулось на несколько итераций — с анимациями, правкой фотографий дресс-кода и финальным деплоем на продовый домен.
Стек и дизайн-система
Для лендинга выбрал Next.js — он уже используется в экосистеме pashavin.ru, знаком, и SSG из коробки даёт хорошую скорость без лишней настройки. Дизайн выстроил вокруг уже существующей системы: тёмный фон, оранжевый акцент #FF4F00, шрифт Syne. Анимации — GSAP с посимвольным появлением заголовка в Hero-секции.
Структура страницы получилась такой:
- Hero — большой заголовок «НАВСТРЕЧУ К AI», бейдж «Мероприятие состоялось», дата/место
- Marquee — бегущая строка с ключевыми словами (нейросети, ChatGPT, Midjourney)
- О мероприятии — описание и 4 статистических карточки
- Спикеры — три карточки с именами и ролями
- Программа — таймлайн с 9 пунктами (18:00–20:30)
- Дресс-код — с визуальными примерами
По структуре компонентов ничего нестандартного — обычные React-компоненты, тайпизированные TypeScript, стили через CSS Modules.
// Пример Hero-компонента с GSAP
useEffect(() => {
const chars = headingRef.current?.querySelectorAll('.char');
if (!chars) return;
gsap.from(chars, {
opacity: 0,
y: 40,
stagger: 0.04,
duration: 0.6,
ease: 'power3.out',
});
}, []);Деплой через Dokploy
Когда локальный билд прошёл чисто (next build без ошибок), встал вопрос деплоя. Решение — Dokploy, который уже крутится на VPS и управляет другими проектами. Флоу получился такой:
- Создаём публичное GitHub-репо (
bugle-c/18ai) - В Dokploy создаём приложение, указываем
buildType: dockerfile - Добавляем домен
18ai.pashavin.ru— DNS уже настроен через wildcard A-запись - Настраиваем webhook для автодеплоя при пуше в
master - Let's Encrypt цепляется автоматически
Первый деплой споткнулся: Dokploy не нашёл Dockerfile — искал по пути code/Dockerfile, а не в корне. Фикс — явно указать dockerfilePath в настройках приложения. После этого билд прошёл и сайт поднялся на https://18ai.pashavin.ru.
# Dockerfile (упрощённо)
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]Правка дресс-кода: детали имеют значение
Казалось бы, мелочь — раздел с дресс-кодом. Но именно здесь случилось несколько итераций. Изначально добавил 6 фото с Unsplash (3 мужских + 3 женских) — скачал локально, чтобы не зависеть от внешнего CDN. Пользователь посмотрел и сказал: «Фото не подходят». Конкретно — мужское «Пиджак + рубашка» и женское «Элегантный образ».
Заменил оба. Женское первое скачивание дало файл в 29 байт — явно 404 с Unsplash. Взял другой запрос, скачал нормальное фото с элегантным платьем с открытыми плечами. Мужское заменил на кадр с пиджаком, рубашкой и галстуком — именно тот «фестивальный, но нарядный» образ.
Одновременно убрал лайтбокс — изначально фото открывались в полноэкранном просмотре по клику, но это оказалось лишним поведением. Заменил <button> на <div>, вычистил весь лайтбокс-стейт и колбэки:
// До: кликабельная обёртка с лайтбоксом
<button onClick={() => openLightbox(idx)}>
<Image src={photo.src} alt={photo.alt} fill />
</button>
// После: просто блок, без интерактивности
<div className={styles.photoCard}>
<Image src={photo.src} alt={photo.alt} fill />
</div>Мелочь, но такие вещи влияют на ощущение от сайта — когда что-то ведёт себя неожиданно, это раздражает.
PostBot: от RSS-таймаутов к умной очереди
Параллельно с лендингом шла работа над pasha-vin-post-bot — Telegram-ботом, который мониторит RSS-ленты AI-тематики, скорит контент через Claude Haiku и рерайтит интересное через Claude Sonnet для публикации в канале.
Пришли логи: три источника подряд дали Request timed out after 60000ms. Reddit, engraved.blog, substack Гэри Маркуса — все падают с таймаутом. Это симптом более глубокой проблемы: источники обрабатывались последовательно, и один зависший блокировал весь цикл на минуту.
Но таймауты — это была лишь вершина айсберга. Основная боль: из ~50 рерайтов в день одобрялось только 3–5. Approval rate 8–10% при стоимости Sonnet-рерайта означал ~$2/день на контент, который сразу летит в корзину.
Дизайн решения: три слоя
В ходе брейнсторминга пришли к гибридному подходу (условный «Подход C»):
Слой 1 — RSS Resilience. Явный таймаут 30 секунд через AbortController вместо дефолтных 60–120с. Один retry через 5 секунд. Параллельный fetch всех источников через Promise.allSettled вместо последовательного for...of. Auto-disable после 5 consecutive failures с уведомлением.
// До: последовательный fetch
for (const source of sources) {
const items = await fetchRSS(source.url); // блокирует на 60с при таймауте
}
// После: параллельный с таймаутом
const results = await Promise.allSettled(
sources.map(source => fetchWithTimeout(source.url, 30_000))
);Слой 2 — Feedback Loop. При нажатии ❌ Reject в модерации — quick-reason кнопки: 👎 Скучно, ✍️ Плохой рерайт, 🔁 Дубль. Причина сохраняется в поле rejection_reason. Топ-3 примера одобренных постов и топ-3 примера отклонённых с причинами добавляются в скоринг-промпт Haiku динамически — бот учится на реальных решениях.
Слой 3 — Topic Freshness. При одобрении поста Haiku извлекает 1–3 ключевых топика (например, ["claude-4", "anthropic-release"]) и сохраняет в поле topics. При следующем скоринге проверяется: если похожий топик публиковался меньше 48 часов назад — скор автоматически снижается. Это решает проблему «пять статей про одно и то же в один день».
Отказ от ручных команд в пользу inline-кнопок
Отдельная история — переработка флоу с кандидатами. Изначально был задизайнен /digest — команда, которая показывает список кандидатов, после чего нужно написать «1 3 5» для выбора нужных.
Пользователь сказал прямо: «Нафиг мне это надо, я не собираюсь вручную заходить и писать команды». И он прав — это лишнее трение.
Новый флоу: каждый кандидат с score выше порога сразу отправляется отдельным сообщением в отдельный топик группы «Кандидаты». Под каждым — две inline-кнопки: ✅ Рерайт и ❌ Скип. Тап на «Рерайт» — запускает рерайт и отправляет результат в модерацию. Тап на «Скип» — помечает как skipped, сообщение обновляется. Через 4 часа авто-фоллбек подбирает всё, до чего не дотронулись руками.
До:
Крон → кандидаты в БД → «📋 5 кандидатов. /digest для выбора»
→ Пишешь /digest → пишешь «1 3 5» → рерайт
После:
Крон → кандидаты в БД
→ Каждый кандидат → отдельное сообщение в топик с кнопками
→ Тапаешь ✅ → рерайт сразу
→ Через 4ч авто-фоллбек для непросмотренных
Это убрало из кодовой базы: команду /digest, команду /digest_all, обработчик текстовых номеров, in-memory массив digestCandidateIds.
Ожидаемые результаты
| Метрика | До | После | |---|---|---| | Sonnet рерайтов/день | ~50 | ~10–15 | | Approval rate | ~8% | ~30–50% | | Стоимость рерайтов/день | ~$2.00 | ~$0.40–0.60 | | Блокировка на RSS таймауте | до 2 мин/source | макс 30с + параллельно |
Экономия ~$1.40–1.60/день выглядит скромно в абсолютных числах, но это 70%+ от текущих расходов на AI-вызовы в боте. При этом качество финального контента должно вырасти — бот будет учиться на реальных одобрениях, а не гадать.
Что я вынес из этого спринта
Первый урок — итеративность дороже перфекционизма на старте. Лендинг вышел живым не потому что я заранее всё спроектировал идеально, а потому что быстро задеплоил рабочую версию и правил по реальному фидбэку. Фото дресс-кода поменялись дважды. Лайтбокс появился и исчез. Это нормально — лучше так, чем неделю делать «идеальный» макет, который никто не видит.
Второй урок — UX бота важен так же, как UX продукта. Команды /digest + ввод номеров казались рабочим решением в теории. На практике это трение, которое никто не захочет проходить каждый день. Inline-кнопки с одним тапом — это другой пользовательский опыт, и разница в удобстве огромная. Если инструмент неудобен — им не пользуются, и тогда вся автоматизация теряет смысл.
Третий урок — метрики без контекста обманывают. 50 рерайтов в день звучит как активная работа. Но если одобряется 4 из них — это не работа, это дорогой генератор мусора. Важно смотреть не на объём активности, а на конверсию каждого этапа пайплайна. Как только мы посмотрели именно на это, решение стало очевидным: не рерайтить всё подряд, а сначала выбирать.
Четвёртый урок — обучение на собственных данных работает лучше prompt engineering в вакууме. Добавить в скоринг-промпт динамические примеры одобренных и отклонённых постов с причинами — это не сложная фича, но она принципиально меняет качество скоринга. Модель получает конкретный контекст «что нравится этому конкретному человеку» вместо абстрактных критериев. Это и есть персонализация, которая работает.
И общий вывод: хорошая разработка — это не только код. Это диалог между тем, что сделано, и тем, как это реально используется. Конференция прошла не идеально — техничка подвела. Но сайт остался, бот стал умнее, и следующий раз будет лучше. Собственно, ради этого и пишем в блог.
Ссылки по теме:

Фулстек-разработчик, строю SaaS-продукты и автоматизации на Next.js, Python и AI. Пишу о реальных кейсах из продакшена.
Связанный проект
Смотреть в портфолио →