staskoz.com — сайт для брата-кинезиолога: автогенерация блога из Telegram и Lighthouse 90+
Сайт-визитка с автоблогом: брат пишет в Telegram-канал, сайт обновляется сам. На старте импортировал 100 постов одним прогоном.
Зачем
Брат — кинезиолог в Геленджике, ведёт 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 триггерит пересборку и деплой. Брат просто пишет в канал — сайт обновляется сам.
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+:
- Partytown для аналитики. GA + Яндекс.Метрика (~250 КБ JS) уехали в Web Worker через
@qwik.dev/partytown. На главном потоке остался только тонкий проксиgtag/ym → forward— существующиеtrackBookAppointmentпродолжают работать без изменений. - Inline critical CSS через
beasties(наследник Critters). Стили above-the-fold инлайнятся в<style>, оригинальный CSS грузится черезrel="alternate stylesheet preload" + onload. Минус 180 мс рендер-блокинга. font-display: optionalвместоswap. PSI показывал LCP 5.0 с, локально — 2.7. Разница — repaint, когда приезжает Nunito поверх системного фолбэка: для кириллицы Next-овская adjusted-fallback не идеальна, и второй пейнт триггерил LCP. Сoptionalбраузер просто использует фолбэк, если шрифт не приехал за ~100 мс. LCP залочился на FCP.content-visibility: autoна секциях ниже фолда. Браузер пропускает layout и paint для семи below-fold секций, пока они не скроллнулись в viewport. CLS остаётся 0 за счётcontain-intrinsic-size.- 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-каркас
Под коммерческие запросы — отдельные посадочные:
/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).