Дисклеймер сразу: я не собирался писать «ещё один стартап». Мне просто было лень руками пересматривать видео, чтобы выписать из него тезисы. Дальше — история о том, как решение личной проблемы обросло LLM-цепочками, защитой от SSRF и вопросами вроде «а что если сертификат не выпустится».
Сервис называется postvid.ru: ссылка на видео или файл на входе, расшифровка, тезисы, таблица, презентация и карточки для карусели на выходе.
Стек, если коротко
FastAPI — веб-приложение и API, ничего экзотического.
faster-whisper, модель
small— расшифровка речи. Брал именноsmall, потому что сервер без GPU, аmediumна CPU превращает ожидание в пытку.YandexGPT / любой OpenAI-совместимый API — выделение тезисов, с фолбэком на локальную модель через Ollama и на офлайн-алгоритм без нейросети вообще.
Pillow — генерация карточек карусели.
openpyxl, python-pptx — таблица и презентация из тех же тезисов.
SQLite — аккаунты, сессии, подписки, платежи. Да, SQLite, не Postgres — для текущей нагрузки хватает с запасом, а лишнюю инфраструктуру я себе усложнять не стал.
Docker Compose + Caddy — деплой и автовыпуск TLS-сертификатов сразу для нескольких доменов.
ЮKassa — оплата и автопродление.
Пайплайн
ссылка/файл → проверка SSRF → faster-whisper (текст + таймкоды) → LLM (тезисы + цитаты + время) → {xlsx, pptx, PNG-карусель, srt}
Задача обрабатывается в пуле потоков, статус лежит в памяти процесса и отдаётся polling’ом через /api/jobs/{id}. На бумаге — просто. На практике каждый пункт этой схемы принёс свой сюрприз, и вот четыре самых запоминающихся.
Грабли №1: облако решило отдохнуть, а сервис — вместе с ним
Первая версия дёргала облачную LLM напрямую, без всякой защиты. Всё было отлично, пока однажды ночью модель не легла на пару минут. Для меня это была строчка в логе. Для пользователей в этот момент — «сервис сломан, ничего не работает». Неприятное ощущение, когда узнаёшь об этом постфактум.
Сделал цепочку:
Облачная модель.
Если недоступна — локальная модель через Ollama на том же сервере.
Если и её нет — офлайн-алгоритм: рубит текст на предложения и выбирает самые «весомые» по простым эвристикам.
Плюс кулдаун: если облако упало, следующая минута задач сразу идёт в запасной вариант, не тратя время на повторный обречённый запрос. На странице результата честно пишется, какой движок сработал — cloud, local или offline. Мне кажется, это важнее любой красивой анимации загрузки: пользователь должен понимать, почему результат вдруг стал немного хуже.
Грабли №2: «просто вставь ссылку» — ха
Казалось бы, что сложного в том, чтобы скачать видео по ссылке. Сложного оказалось много: нужно отсечь запросы во внутреннюю сеть сервера (привет, SSRF), проверить, что схема — именно http(s), и не пускать всё подряд.
def validate_public_url(url: str, allowed_hosts: set[str] | None = None) -> None: parsed = urlparse(url.strip()) if parsed.scheme not in ("http", "https"): raise SourceRejected("Разрешены только http(s) ссылки") # резолвинг хоста, отказ на приватные/внутренние диапазоны адресов
Отдельно — белый список разрешённых хостов (ALLOWED_HOSTS), если хочется ограничить сервис конкретными площадками, а не пускать вообще любую ссылку из интернета.
Грабли №3: сайт на двух доменах, и Caddy не должен запутаться
В какой-то момент сайт понадобилось посадить сразу на два домена — например, чтобы старый адрес просто уводил на новый, а не дублировал сайт целиком. Сделал два режима: EXTRA_DOMAINS — полноценная копия сайта (с noindex и canonical на основной, чтобы не словить дубли в поиске), и REDIRECT_DOMAINS — простой 301-редирект без второй копии вообще.
{$DOMAIN}, {$EXTRA_DOMAINS:localhost} { reverse_proxy app:8000 } {$REDIRECT_DOMAINS:redirect-domains.localhost} { redir https://{$DOMAIN}{uri} permanent }
Домен вычисляется по заголовку Host, но только если он есть в белом списке — иначе можно было бы подменить его и получить ссылку входа или оплаты на чужом адресе. Мелочь, но именно такие мелочи потом читаешь в чужих постмортемах.
Грабли №4: доверяй, но перепроверяй (ЮKassa)
ЮKassa шлёт вебхук об оплате — удобно, но доверять ему вслепую я не рискнул. Перед тем как выдать тариф, сумма и статус платежа перепроверяются прямым запросом к API ЮKassa. Для автопродления — то же самое плюс отдельный воркер, который раз в 10 минут смотрит, у кого подошёл срок. Если банк отказал три раза подряд — автопродление тихо отключается, а не долбит его до посинения.
Что дальше, если честно
Очередь задач и их статусы сейчас живут в памяти процесса — работает, пока сервис не перезапускают посреди активной обработки. При росте нагрузки это первое, что придётся переделывать на внешнюю очередь. Пока не переделываю: делать инфраструктуру под нагрузку, которой ещё нет, — так себе трата времени одного разработчика.
Если интересны детали какого-то конкретного модуля — расшифровка, LLM-цепочка, генерация карточек — спрашивайте в комментариях, отвечу подробнее, чем позволяет формат статьи.