staskoz.com — сайт для брата-кинезиолога: автогенерация блога из Telegram и Lighthouse 90+

Сайт-визитка с автоблогом: брат пишет в Telegram-канал, сайт обновляется сам. На старте импортировал 100 постов одним прогоном.

staskoz.com — главная: герой, ссылки на услуги, контакты, рейтинг Яндекс.Карт

Зачем

Брат — кинезиолог в Геленджике, ведёт Telegram-канал @staskinesio. Каждый пост — фотография техники, видео упражнения или короткий разбор кейса. Хотелось:

  • одну точку входа: «найди меня в Google → запишись», без копания в постах канала;
  • блог, который пополняется сам, без редактора и CMS;
  • SEO-конкурентность по «остеопат Геленджик», «лечение боли в шее» и похожим запросам.

Как устроена автогенерация блога из Telegram

Главная техническая штука проекта. У меня есть Node-пакет koztv-blog-tools, вытащенный из логики, которая уже работает на моих других сайтах. Он умеет:

  • подключаться к Telegram через Telethon-совместимую сессию;
  • скачивать сообщения батчами по 30, останавливаясь на первом уже существующем;
  • группировать многосообщенческие посты (Telegram режет длинные тексты на 2-3 сообщения, надо склеить обратно);
  • дедуплицировать по msg_id;
  • сжимать видео ffmpeg-ом до разумного размера;
  • раскладывать всё в public/posts/{id}/ru.md + рядом медиа.

Поверх этого — GitHub Action, который запускается ежедневно в 4:00 МСК (1:00 UTC), вызывает пакет, коммитит дельту, и через workflow_run триггерит пересборку и деплой. Брат просто пишет в канал — сайт обновляется сам.

Блог: 100 статей, чипы категорий, поиск, masonry-сетка. Категории расставляются по ключевым словам с весами.

Telegram оказался не «просто источником текста». Что сломалось по пути:

  • Markdown из ботов и веб-клиента отличается. Многострочный bold ломал парсер. Лечилось регулярками в koztv-blog-tools.
  • Слаги генерировались с мусором из Attachments: блока. Отдельный cleanContent срезает приложения до генерации URL.
  • Категоризация по весам ключевых слов, а не по первому совпадению — иначе пост про разминку шеи попадал в «Образ жизни» вместо «Упражнения».

Перформанс: с 80 PSI до 90+

Сайт — статика на Next.js 16 (output: 'export'). Казалось бы, что там тормозить. Но мобильный PSI с эмуляцией медленного 4G и 4× CPU throttling показывал Lighthouse 80-83 стабильно — element render delay съедал бюджет.

Что помогло вытащить в 90+:

  1. Partytown для аналитики. GA + Яндекс.Метрика (~250 КБ JS) уехали в Web Worker через @qwik.dev/partytown. На главном потоке остался только тонкий прокси gtag/ym → forward — существующие trackBookAppointment продолжают работать без изменений.
  2. Inline critical CSS через beasties (наследник Critters). Стили above-the-fold инлайнятся в <style>, оригинальный CSS грузится через rel="alternate stylesheet preload" + onload. Минус 180 мс рендер-блокинга.
  3. font-display: optional вместо swap. PSI показывал LCP 5.0 с, локально — 2.7. Разница — repaint, когда приезжает Nunito поверх системного фолбэка: для кириллицы Next-овская adjusted-fallback не идеальна, и второй пейнт триггерил LCP. С optional браузер просто использует фолбэк, если шрифт не приехал за ~100 мс. LCP залочился на FCP.
  4. content-visibility: auto на секциях ниже фолда. Браузер пропускает layout и paint для семи below-fold секций, пока они не скроллнулись в viewport. CLS остаётся 0 за счёт contain-intrinsic-size.
  5. Pre-compressed assets. .gz сиблинги генерятся на билде на уровне 9 (~5% компактнее runtime-уровня 5, и 0 CPU на запрос). Nginx раздаёт через gzip_static.

По пути словил классическую регрессию: после рефакторинга в Server Components размер index.html удвоился (120 КБ → 210 КБ). Каждый <Reveal> создавал server→client границу, и RSC-стрим сериализовал каждую секцию как props. Откатил главную в 'use client', заменил 27 IntersectionObserver-ов на один общий RevealActivator в корневом layout-е.

Деплой: 1.1 ГБ → 100 МБ

Каждый push гонял через ghcr 1.1 ГБ образа, потому что в nginx-слой бейкалось 956 МБ Telegram-медиа. Обновил один пост — целый гигабайт по сети.

Что сделал:

  • Dockerfile: rm -rf out/posts после билда. HTML всё ещё ссылается на /posts/{id}/..., но файлы теперь живут на хосте.
  • Отдельный rsync-джоб гонит public/posts/ в /var/www/staskoz-posts/ инкрементально (только дельты).
  • Контейнер маунтит этот volume read-only.
  • HEALTHCHECK в Dockerfile, деплой ждёт healthy перед завершением.
  • Caddyfile рендерится из шаблона; mv + reload только если md5 реально отличается.

Итог: образ 100 МБ вместо 1.1 ГБ, ежедневный cron-деплой блога ~1 минута вместо 3-4. Хостинг — Caddy + nginx-alpine на той же машине, что и другие мои сайты, TLS через Let's Encrypt автоматически.

SEO-каркас

Лендинги по состояниям: боль в пояснице, шее, грыжа, протрузия, кифоз, защемление — все с одинаковой структурой и FAQ

Под коммерческие запросы — отдельные посадочные:

  • /lechenie/{состояние}/ — 7 страниц по типам боли и нарушений осанки.
  • /uslugi/{услуга}/ — массаж, мануальная, кинезиология.
  • /lechebnyy-massazh/ — узкий лендинг под Геленджикский гео-запрос.
  • JSON-LD везде: MedicalBusiness, MedicalProcedure, FAQPage с реальными вопросами.

Отдельная история про копирайт: брат после ревью попросил убрать «без хруста» с главной. Хруст в техниках случается, просто не агрессивный. Перенесли честный разбор в FAQ — какая техника, какой именно хруст, почему это нормально. По SEO не просели, а доверие на посадочной выросло.

Стек

Next.js 16 (App Router) + React 19 + Tailwind v4 + Framer Motion, статика через output: 'export'. Контент из Telegram через npm-пакет koztv-blog-tools (мой). Аналитика в Partytown. Хостинг: Docker + nginx-alpine за Caddy на собственном сервере, TLS — Let's Encrypt. GitHub Actions — два workflow: update-blog.yml (cron, тянет из Telegram) и deploy-server.yml (push → build → rsync → restart).

Посмотреть

staskoz.com