Последние полгода я работаю над VibeMuvik — кино-соцсетью с рецензиями, дебатами и синхронным просмотром фильмов. Одна из тех штук, которые «ну вроде несложно», пока не начинаешь копать.
Эта статья — про неожиданные находки. Не про «как я выбрал стек» (скучно) и не про «туториал по WebRTC» (и без меня есть). Это пять ситуаций, в которых я споткнулся, обнаружил что-то интересное, и подумал «об этом стоит рассказать — другим пригодится».
Поехали.
1. Одна строчка в robots.txt, которая съедает crawl-бюджет Google
Свежий кейс. Смотрю логи сервера — Googlebot тысячу раз за сутки дёргает внутренний XHR-эндпоинт, который фронтенд вызывает при рендеринге. Думаю: «Стоп. Как он вообще туда попал? У меня же в robots.txt запрещено.»
Открываю robots.txt. Читаю. Вроде всё правильно:
User-agent: *Disallow: /api/Disallow: /admin/Disallow: /profile/# ... ещё 15 запретовUser-agent: YandexBotCrawl-delay: 1Allow: /User-agent: GooglebotAllow: /
Disallow: /api/
Disallow: /admin/
Disallow: /profile/
# ... ещё 15 запретов
User-agent: YandexBot
Crawl-delay: 1
Allow: /
User-agent: Googlebot
Allow: /
Логика в голове: «для всех ботов запрещаю то-то, а для именных — разрешаю всё».
А вот нихрена. По RFC 9309 и всему здравому смыслу robots-стандарта бот использует ровно один блок — самый специфичный к его User-Agent. Если есть блок Googlebot — общий * полностью игнорируется.
То есть Googlebot видел одно:
User-agent: GooglebotAllow: /
Allow: /
И радостно шёл индексировать весь /api/*. Включая служебные эндпоинты. Включая трекинг показов рекламы — в статистике показов рекламы у меня стали появляться впечатления от бота, с IP из диапазона Google Crawler.
Фикс простой и неприятный
Нужно продублировать Disallow в каждую именованную секцию:
User-agent: *Allow: /Disallow: /api/# ...User-agent: YandexBotCrawl-delay: 1Allow: /Disallow: /api/ ← это было пропущено# ...User-agent: GooglebotAllow: /Disallow: /api/ ← и это# ...
Allow: /
Disallow: /api/
# ...
User-agent: YandexBot
Crawl-delay: 1
Allow: /
Disallow: /api/ ← это было пропущено
# ...
User-agent: Googlebot
Allow: /
Disallow: /api/ ← и это
# ...
Мораль: если у вас есть именованные секции, каждая должна быть самодостаточной. Не наследует от *. Многие сайты на это попадаются — я проверил robots.txt у десятка русскоязычных SaaS-проектов, у трёх нашёл ту же самую ошибку.
Проверьте свой. Серьёзно.
2. Математика вкуса: почему 24 оси, а не 5 «жанров», и при чём тут косинус
В обычной рекомендательной системе вкус ужимают до жанров: «любит боевики, не любит мелодрамы». Летает на нас с анонсами Netflix «because you watched Fast & Furious» — и промахивается.
Промахи не потому, что ML плохой. Потому что жанр — это на уровне каталога, а не вкуса. Два фильма одного жанра могут быть полярно разными для конкретного человека. «Бегущий по лезвию 2049» и «Трансформеры» — оба «фантастика+боевик». Зайти к любителю «Бегущего» с «Трансформерами» = обидеть.
Что я сделал в Cinema DNA
Разложил вкус на 24 оси — не жанровых, а структурных. Примеры по категориям:
Темп и композиция
-
Темп повествования
-
Терпимость к длинным сценам
-
Сложность нарратива (линейность ↔ фрагментированность)
Эмоциональная природа
-
Эмоциональная плотность
-
Нужен ли катарсис
-
Насколько тяжёлые темы готов смотреть
Форма против содержания
-
Важность визуального стиля
-
Толерантность к авторскому почерку
-
Играет ли фильм с формой
Контекст просмотра
-
Оптимальная длина
-
В компании / одному
-
Настроение
Каждая ось — ползунок. На выходе теста — точка в 24-мерном пространстве.
Почему комбинация метрик, а не только косинус
Первое, что приходит в голову для поиска «похожих» пользователей — косинусная близость: углы между векторами. Работает, но даёт контринтуитивные результаты в граничных случаях.
Простой пример: человек А ответил на все вопросы «50/100» (нейтрально, не задумываясь), а человек Б ответил честно, с разбросом. Косинус между их векторами может выйти 0.95+ (по направлению близки), но на практике это два совершенно разных человека — один не определился, другой имеет чёткий профиль.
Поэтому в расчёте совместимости участвует не только косинус, но и мера расстояния (например, евклидова, нормализованная). Одно говорит о направлении вектора, другое — о «громкости» профиля. Взвешенная комбинация этих двух даёт адекватные результаты:
-
Два случайных юзера — 35-55%
-
Юзеры с пересечением любимых фильмов — 80-95%
-
Полярные профили — ниже 20%
Конкретные веса и нормализацию тюнил эмпирически. Это не открытие, просто напоминание: когда кажется, что одной метрики достаточно — скорее всего, её недостаточно.
Масштабирование
Пока юзеров немного, поиск похожих работает полным перебором O(N). На сотнях тысяч переведу на pgvector с ANN-индексом (ivfflat для старта, hnsw если понадобится качество). Это известная задача, решение найдено, просто до этой точки ещё ехать.
3. Синхронный просмотр: пять вещей, на которых я споткнулся с WebRTC
Фича: «смотрим фильм вместе, когда в разных городах». Два человека заходят в комнату, загружают свою копию фильма, смотрят синхронно, болтают голосом. Звучит как «ну WebRTC + play/pause-события, 2 дня работы». В реальности — три недели.
Пять неожиданных проблем, с которыми столкнулся:
3.1. NAT traversal не решается «из коробки»
Голый peer-to-peer без TURN-сервера не пробивает NAT у 40% домашних роутеров. Я наивно начинал с STUN-only и нескольких итераций «работает у меня, у брата не работает». Причина — симметричный NAT, классика мобильных операторов и некоторых провайдеров.
Решение — свой TURN-сервер. Opensource реализации есть, разворачиваются за вечер. Без TURN — не берите за фичу P2P-видео «для всех пользователей». Только для своих, у кого IPv6 или нормальный NAT.
3.2. captureStream() на — не волшебная палочка
Да, из DOM-элемента <video> можно получить MediaStream и отправить по WebRTC. Работает. Но:
-
DRM-контент не отдаётся. Вы не сможете стримить Netflix-видео из тега
<video>— там поток через EME, иcaptureStreamвернёт либо пустоту, либо чёрный кадр. -
Браузеры отдают разные частоты кадров. Chrome честно стримит 30-60fps, Firefox иногда ограничивает. Safari до недавнего времени вообще не поддерживал
captureStreamдля HLS-видео. -
Аудиодорожки в некоторых форматах (многоканальные Dolby) захватываются криво или не целиком.
Урок: всегда тестируйте на 3-4 браузерах с реальными форматами контента, не только с .mp4 + h264 + aac.
3.3. Autoplay policies на гостевой стороне
Когда хост играет видео, гостю приходит поток через WebRTC. Вы делаете guestVideo.srcObject = stream и думаете «всё, готово». Видео не стартует.
Почему: современные браузеры блокируют автоплей видео со звуком без пользовательского действия. Даже если поток пришёл вживую. Решение — гостю нужно самому нажать Play (или muted=true на старте, потом unmute по клику).
Это звучит банально, но я потратил полдня отладки, пока не вспомнил.
3.4. Голос и видео — лучше два отдельных peer connection
Соблазн: один peer connection, в нём и видео фильма, и голоса. Меньше overhead, проще код.
Реальность: юзеры постоянно хотят мьютить звук фильма, чтобы слышать собеседника. С одним peer это превращается в manage отдельных audio track enable/disable, состояния синхронизировать между участниками, и т.д.
Два независимых peer (один — видео фильма с видео+аудио, второй — только голос-peer) — кардинально упрощают UX-логику. Да, немного больше нагрузки на сеть. Но вы получаете чистое разделение «это фильм — могу заглушить» и «это собеседник — не могу потерять».
3.5. Синхронизация play/pause — это не просто «посылай событие»
Хост жмёт паузу в 15:42:18.123. Событие летит через WebSocket на сервер. Сервер рассылает гостям. Гость получает его в 15:42:18.456.
Если у гостя в момент прихода события currentTime = 15:42:18.410 (потому что у него видео шло своим ходом с минимальным дрейфом), а вы просто делаете guestVideo.pause() — они будут стоять на разных кадрах.
Нужно не просто паузить, а выравнивать currentTime по хосту с компенсацией latency. Плюс периодический re-sync — каждые 10 секунд хост шлёт свой currentTime, гости, если ушли на >300ms, подтягиваются.
Мелочь, но именно эти мелочи отделяют «работает прикольно» от «работает и ты забываешь, что фильм показывается через сеть».
4. SEO-грабли на Next.js 14: ISR и 11-тысячный sitemap
Если у вас на сайте больше 1000 страниц контента, которые попадают в sitemap — внимание.
Next.js App Router позволяет сделать sitemap.xml как route в app/sitemap.ts. Вы возвращаете массив URL, Next строит XML. Красиво.
Подводный камень: этот route работает через ISR (incremental static regeneration). Если не поставить export const revalidate = <big number>, то каждый запрос sitemap заставляет пересобирать весь список URL. А чтобы собрать URL, нужно сходить в БД за всеми 11 000 фильмами, 2000 персонами, 300 коллекциями. Каждый запрос.
Я сначала поставил revalidate = 1800 (30 минут), думая «sitemap же не критично свежий, 30 мин хватит». Получил ситуацию: каждые 30 минут мой API генерит 11k-URL ответ на запрос sitemap от GoogleBot, YandexBot, каждого curl от мониторинга и т.д.
Поднял до 86400 (24 часа). Стало нормально. Крутить выше — не надо, Google может решить что ваш sitemap stale.
Ещё подробность: структурированные данные
JSON-LD с @type: Movie и @type: Person на страницах фильмов/людей — сильно улучшает показы в rich results Google. Не поленитесь. Правильные schema.org-поля (actor, director, contentRating, ratingValue) Google использует в карточках поиска. Это +15-30% CTR на страницу фильма по моим наблюдениям.
5. IndexNow — API, который реально работает (и его почему-то мало используют)
IndexNow — протокол, который поддерживают Bing, Yandex, Seznam, Naver и через который вы можете пинговать поисковики при публикации новой страницы. Индексация — за минуты вместо недель.
Протокол простой до идиотизма:
-
Генерируете случайный ключ
-
Кладёте файл
{ключ}.txtв корень сайта -
Шлёте POST на
https://api.indexnow.org/indexnowс JSON{host, key, urlList}
Всё. На это у меня ушёл час, включая тесты.
Google IndexNow не поддерживает (у них свой API через Search Console, ручной). Но Bing + Yandex — это уже ~15% российского поискового трафика + международный. И через Bing по цепочке идёт индексация в ChatGPT, DuckDuckGo, Copilot — для LLM-видимости это значимо.
Странно, что об IndexNow так мало пишут. Возможно потому, что Google не в клубе, и русские технические блоги этот протокол часто игнорируют. Зря.
Что в итоге
Пять историй, пять граней одной и той же мысли: проект, который снаружи выглядит «стандартный CRUD + REST», внутри содержит десятки нетривиальных технических решений, про которые в учебниках не написано.
Если интересно посмотреть результат — VibeMuvik открыт, пользование бесплатное, регистрация — минута. Буду рад багрепортам, идеям и критике архитектурных решений.
Хочу написать ещё — есть темы:
-
Как устроены батлы и дебаты (голосование с аргументами, почему это не просто «опрос»)
-
Производительность PostgreSQL: партициирование по времени, материализованные представления для городской статистики, когда
CREATE INDEX CONCURRENTLYспасает, а когда — нет -
Анализ ошибок авторизации и 2FA: что я узнал, читая свой audit log
Если хоть одна из этих тем интересна — пишите в комментариях, какую развернуть следующей.
Спасибо за чтение.
ссылка на оригинал статьи https://habr.com/ru/articles/1027876/