Дисклеймер сразу: я не собирался писать «ещё один стартап». Мне просто было лень руками пересматривать видео, чтобы выписать из него тезисы. Дальше — история о том, как решение личной проблемы обросло 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-цепочка, генерация карточек — спрашивайте в комментариях, отвечу подробнее, чем позволяет формат статьи.

Комментарии (0)