П/ВИН

Как я построил лендинг и ускорил PostBot за один спринт

·8 мин чтения

Иногда несколько дней разработки вмещают в себя столько всего, что потом долго не понимаешь, с чего вообще начать рассказывать. Вот именно такой получился этот спринт: лендинг 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 и управляет другими проектами. Флоу получился такой:

  1. Создаём публичное GitHub-репо (bugle-c/18ai)
  2. В Dokploy создаём приложение, указываем buildType: dockerfile
  3. Добавляем домен 18ai.pashavin.ru — DNS уже настроен через wildcard A-запись
  4. Настраиваем webhook для автодеплоя при пуше в master
  5. 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. Пишу о реальных кейсах из продакшена.

Связанный проект

Смотреть в портфолио →