Ночной клуб, два ресторана и гостиница. У каждого свой бренд и своя манера говорить, площадок пять, а SMM-щик на всё это один. Я собрал систему, которая сама планирует рубрики, пишет тексты, рисует картинки, монтирует ролики и публикует их по расписанию. Ещё она выводит сообщения гостей на экран над входом, принимает брони столов, ведёт учёт концертов, отвечает на отзывы и следит за рекламой. За четыре месяца вышла 1 821 публикация, просмотров набралось около двух миллионов. Человек задаёт правила и даёт добро на рискованные шаги, остальное завод делает сам.
В мае у меня на руках оказались четыре заведения одного владельца, все в одном здании. Стиль и публика у них разные, правила тоже. Клубу нужны афиши на выходные и ролики, ресторану надо продавать меню и банкеты, гостинице рассказывать про номера и командировки.
Двадцать участков. Раньше это была чья-то ручная работа, а что-то не делалось вовсе. Цифры взяты из рабочей базы завода на 24 сентября.
У каждого заведения своя ротация рубрик по дням и часам, с недельным сдвигом фазы, чтобы понедельник не повторял прошлый понедельник.
Новая рубрика собирается в панели без программиста: инструкция для Gemini, дни, время, площадки, свой пул фото или промптов.
32 свои рубрики · 149 пуловподробнее →Тексты, картинки, видео, разбор фото и модерацию завод делает через браузерную сессию Gemini на сервере, так же, как работал бы человек.
27 модулей завода работают с Geminiподробнее →Раз в шесть часов забирает новые фото из альбомов ВК и размечает. Под пост подбирает кадр нужной ориентации, который не стоял в ленте 30 дней.
7 099 файлов в банкеСтатьи для ресторана: темы без повторов, свой календарь выхода, отдельный лимит, чтобы не выжигать сессию Gemini.
41 статья опубликованаКаждые 15 минут собирает просмотры и реакции со всех площадок. По ним правится план: какие рубрики и слоты работают.
≈2 млн просмотровШаблоны, автомонтаж с субтитрами, монтаж по чужому референсу, сборка по паттерну, кружки Telegram, ролики Veo.
34 композиции Remotionподробнее →Агент на компьютере с After Effects сам находит макет афиши, рендерит ролик и отдаёт его заводу. Ролик встаёт на пятницу и субботу.
Афиша на фасадном экране собирается из программы вечера. Гость пишет боту, и через несколько секунд его текст уже над входом.
161 сообщение в эфиреподробнее →Пост и история расходятся по VK, Telegram, Instagram, YouTube и TikTok. У каждой площадки свой API и свои правила.
835 VK · 616 TG · 287 IG · 45 YT · 38 TTподробнее →Отвечает гостям по сценариям, собирает базу подписчиков с тегами, запускает розыгрыши.
4 710 сообщений · 19 розыгрышейУходят сегментами в дни программы, только тем, кто разрешил сообщения.
30 729 отправокВключил вакансии тумблерами, и вышел один пост «нужны официанты и бармены». Дальше текст сам разошёлся по городским чатам, с паузой между повторами.
648 рассылок вакансийРаз в полчаса собирает отзывы, на хорошие готовит ответ в тоне заведения, плохие отдаёт человеку, несправедливые обжалует.
3 094 отзыва · 237 ответов · 24 жалобыМенеджер заведения видит в панели только своё: фильтр на уровне базы и страж на входе API отдают 403 на чужую организацию.
Схемы залов, редактор, журнал и виджет для сайта. Собрал её, чтобы заменить платный сервис за 4 000 ₽ в месяц.
6 залов · 100 столовподробнее →Концерты раньше вели в Excel, теперь в таблице панели: оплаты, контрагенты, выгрузка обратно в тот же файл, новые даты сами подтягиваются с Яндекс.Афиши.
50 мероприятий · 26 контрагентовподробнее →Цифры из кабинетов тянет раз в три часа, правила автопилота проверяет каждый час, а раз в сутки присылает отчёт.
84 кампании под наблюдениемЗавод правит сайты заведений: афиши, мета-теги, посадочные под поисковые запросы. Заявки с форм падают в панель броней.
30 разделов: календарь постов по всем заведениям, медиабанк, видеостудия, отзывы, реклама, бот, брони, экраны, концерты, найм.
39 тыс. строк TypeScriptКонвейер у всех заведений один: исходники → очередь → проверки → площадки, а метрики возвращаются обратно в план. Работает это на трёх машинах: сервер с десятью постоянными службами, компьютер в клубе для экранов и мой ПК для After Effects и тяжёлого рендера.
Бесплатного лимита API на такой поток не хватает, а платить за каждый запрос четырёх заведений выходит дорого. Поэтому завод заходит в Gemini так же, как человек: через браузер на сервере, под аккаунтом Google Pro. Тексты, картинки, видео и разбор изображений получаются без оплаты за запрос, но вокруг живого браузера пришлось много чего построить.
# tools/gemini_session_lock.py
LOCK_PATH = "/tmp/gemini_session.lock"
PROFILE_DIR = "/opt/factory/browser-data"
def cleanup_stale_chrome(profile_dir=None) -> None:
# SingletonLock указывает на мёртвый PID — удаляем,
# иначе Chrome решит, что профиль занят
profile = profile_dir or PROFILE_DIR
sl = Path(profile) / "SingletonLock"
if sl.is_symlink():
target = os.readlink(str(sl)) # hostname-PID
pid_part = target.rsplit("-", 1)[-1]
try:
pid = int(pid_part)
except ValueError:
pid = None
if pid is None or not _pid_alive(pid):
sl.unlink()
for name in ("SingletonCookie", "SingletonSocket"):
(Path(profile) / name).unlink(missing_ok=True)
Выбираешь ролик в медиабанке и жмёшь «Размножить по каналам». Завод создаёт пост в ленты и парную историю туда, где истории есть, а планировщик сам раскладывает всё по площадкам к назначенному времени. За одной кнопкой стоят пять разных интеграций.
| Площадка | Как публикует завод | Особенности |
|---|---|---|
| ВКонтакте | VK API: стена, видео, клипы, истории | своя очередь вместо отложки ВК, проверка, что пост реально на стене; защита от флуда |
| Telegram | Бот API + личный премиум-аккаунт через Telethon | премиум-аккаунт нужен, чтобы в постах работали анимированные эмодзи; истории канала |
| Graph API: посты, карусели, Reels, истории | медиа отдаётся по публичной ссылке завода, своя обложка у Reels | |
| YouTube | Data API: Shorts с докачкой | своя обложка после загрузки |
| TikTok | Полуручная очередь | для официального автопостинга нужен аудит приложения, поэтому завод готовит ролик, обложку и подпись с хэштегами, а человек жмёт «Залито» |
| Discord · MAX | Подключаются в «Подключениях» по организации | для новых проектов, ключи вводятся без кода |
# app/routers/fanout.py — POST /api/fanout/reel
VIDEO_PLATFORMS = ["vk", "tg", "ig", "yt", "tt"]
STORY_PLATFORMS = ("vk", "tg", "ig") # у yt/tt историй нет
post = Post(id=pid, org_id=body.org_id, status="queued",
format="video", format_label="Рилз — все каналы",
text=caption, scheduled_at=when,
cover_media_id=(cover.id if cover else None))
session.add(PostMedia(post_id=pid, media_id=body.media_id))
for p in plats:
session.add(PostPlatform(post_id=pid, platform=p))
# авто-история: тот же ролик туда, где истории есть
story_plats = [p for p in plats if p in STORY_PLATFORMS]
Владелец придумал конкурс, у ресторана появилось бизнес-меню, клубу понадобилась рубрика про резидентов. Раньше под каждую такую идею приходилось лезть в код. Теперь есть форма в разделе «Кастомизация постов»: заполнил её, и рубрика сама встала в контент-план.
# таблица custom_post_categories — одна рубрика slug # адрес рубрики label # название в панели gemini_instruction # что и каким тоном писать example_text # запасной текст, если генерация не удалась weekdays "0,3,5" # пн, чт, сб slot_time "19:00" platforms "vk,tg,ig" default_media_count needs_video priority append_footer # ставить ли подпись с телефоном is_active # тумблер ротации
Для заведения видео обходится дороже всего: съёмка, монтаж, музыка, титры. В разделе «Видео» у завода шесть режимов под разные задачи. Самый простой делает ролик по шаблону за минуту, самый сложный повторяет монтаж чужого вирусного ролика на наших клипах.
| Режим | Что на входе | Что делает завод |
|---|---|---|
| 01 · Шаблон | тексты, фото, цифры | рендерит бренд-шаблон Remotion: афиша, цитата, список, обратный отсчёт, «угадай чек», история номера. В студии есть живое превью, поправил поле и сразу видишь кадр |
| 02 · Автомонтаж | сырое видео с голосом | режет лишние паузы, убирает шум, выравнивает голос, распознаёт речь в субтитры, подбирает музыку и приглушает её под голос |
| 03 · По референсу | чужой ролик + наши клипы | ИИ-режиссёр: разбирает референс по долям бита, Gemini пишет план монтажа, завод подбирает клипы по тегам, проверяет и рендерит |
| 04 · По паттерну | сохранённый стиль + новые клипы | монтаж, который уже разобрали один раз (крупности, энергия, переходы, длины), применяется к новым материалам |
| 05 · Кружки | видео | круглые видео-сообщения для Telegram-каналов |
| 06 · Veo | кадр и описание | ролик 9:16 через Gemini: к фото заведения добавляются движение камеры, свет, люди |
Там же есть ручной редактор с таймлайном в духе CapCut, вырез человека из кадра для текста «за спиной», банк клипов с разметкой и банк музыки. Видео-афиши выходных собирает After Effects: агент на моём ПК сам находит макет афиши, рендерит ролик и отдаёт заводу, а тот ставит его на пятницу и субботу в 12:00.
Файлы взяты как есть из рабочей папки завода, руками их никто не дорабатывал. Каждое заведение в своём бренде: у клуба неон, у гостиницы золото и антиква, у ресторана тёплый красный.
Видео-афиши выходных из After Effects лежат на главной странице.
Над входом в клуб висит экран 1920×576. Завод крутит на нём афишу вечера, а с августа ещё и «стену»: гость пишет боту, и его текст появляется над входом, пока он стоит на улице с телефоном. Для клуба это инфоповод, люди снимают его сами. Ручной модерации нет, так задумано, поэтому фильтр строгий.
Ресторан и клуб платили за внешний сервис броней. Я перенёс в завод всё, что из него было нужно, и добавил то, чего не хватало. Схемы залов снял с прежнего сервиса один в один, с диванами, углами поворота и декором. Виджет открывается по публичной ссылке, его можно поставить на сайт вместо чужого. Заявки с сайта гостиницы уже идут сюда.
Клуб сдаёт зал под концерты приезжих артистов, и всё это жило в таблице Excel: дата, организатор, контакты, сумма договора, три оплаты, комментарии. Я перенёс таблицу в панель так, чтобы работать с ней можно было по-привычному, а рутину забрал завод.
# app/rental_afisha.py
def _norm_artist(s: str) -> str:
# «АК-47 + ТГК» и «АК-47+ТГК» — один артист
return re.sub(r"[^0-9a-zа-яё]", "", (s or "").lower())
def _known_counterparty(session, org_id, artist):
# Возвращает (точное, похожее). Контрагента ставим
# ТОЛЬКО по точному — похожее уходит подсказкой.
a = _norm_artist(artist)
...
for e in rows:
ea = _norm_artist(e.artist)
if ea == a:
return e, None
# похоже — только если совпадение от 5 знаков
if similar is None and min(len(a), len(ea)) >= 5 \
and (a in ea or ea in a):
similar = e
return None, similar
Своя панель на Next.js, 30 разделов: календарь постов по всем заведениям, рубрики, фотобанк, отзывы, реклама, боты, брони, экраны.
Сентябрь короче: 15-го закрылся один из ресторанов, 22-го я закончил работу с заведениями.
Четыре места, где в лоб не получилось бы. Фрагменты кода взяты с сервера, сокращены только комментарии.
Первого сентября я нашёл в очереди гостиницы 5 постов из 30, где вместо текста стояло «Я просто языковая модель, эта задача не для меня», а под этим фирменная подпись. Автопубликация была включена, и первый такой пост вышел бы на следующий день.
Стал разбираться. Проверка на брак жила только внутри копирайтера и срабатывала в момент генерации. Всё, что сгенерилось раньше фильтра или пришло из панели, доезжало до ленты без проверки.
Я перенёс проверку на последний шаг, прямо перед отправкой на площадку. Теперь неважно, откуда взялся текст: брак уходит в черновик с пометкой причины, в паблик он не попадает.
# app/post_text.py
def find_refusal(text: str) -> Optional[str]:
body = split_footer(text or "")[0].lower() # подпись не смотрим
if not body:
return None
for m in REFUSAL_MARKERS:
if m in body:
return m
return None
# app/scheduler.py — _publish_post(), до вызова площадок
_ref = _ptr.find_refusal(post.text)
if _ref:
post.status = "draft"
post.reason = f"текст-отказ ({_ref}) — нужен свежий текст"
session.add(post); session.commit()
return False, [f"отказ ({_ref}) → draft (публикация отменена)"]
У пяти площадок пять разных API, и какой-нибудь обязательно падает: Instagram отдаёт временную ошибку, ВК включает защиту от флуда. Если повторить пост целиком, в Telegram он выйдет второй раз.
Каждый успешный выход записывается отдельной строкой. На повторе планировщик пропускает площадки, куда пост уже ушёл, и добивает только упавшие. После трёх неудач площадка сдаётся и не держит весь пост в очереди.
# app/scheduler.py
already_pubs = session.exec(
select(PostPublication).where(PostPublication.post_id == post.id)
).all()
if force and already_pubs: # осознанная перепубликация
for _p in already_pubs:
session.delete(_p)
already_pubs = []
already_platforms = {p.platform for p in already_pubs}
if already_platforms:
logger.info("post %s already published to: %s — skip those",
post.id, already_platforms)
...
for plat in platforms:
if plat in already_platforms:
continue # сюда уже вышло
Случайный выбор из большого альбома всё равно повторяется: подписчики замечают одну и ту же фотографию через неделю. Отметка «использовано» тоже врёт, потому что фото могли поставить в пост руками из панели.
Поэтому антиповтор отметку не смотрит. Он проверяет, какие файлы реально стояли в постах заведения за 30 дней. Из оставшихся первыми идут живые фото гостей, потом лучшие по оценке, и из верхних сорока берётся случайное.
# app/media_pool_engine.py — _pick_from_library()
cutoff = datetime.utcnow() - timedelta(days=30)
# железный антиповтор: всё, что реально стояло в постах за 30 дней
posted30 = set(session.exec(
select(_PM.media_id).join(_P, _P.id == _PM.post_id)
.where(_P.org_id == org_id, _P.scheduled_at >= cutoff)
).all())
fresh = [m for m in rows
if m.id not in posted30
and (not m.last_used_at or m.last_used_at < cutoff)]
pool_rows = fresh or [m for m in rows if m.id not in posted30] or rows
pool_rows.sort(key=lambda m: (m.photo_score or 0), reverse=True)
return random.choice(pool_rows[:40]).id
У гостиницы 15 рубрик и один пост в день. Простая ротация по кругу за две недели ставит одинаковые рубрики на одинаковые дни недели, и лента начинает выглядеть как расписание.
Хватило одной строки: к номеру дня прибавляется номер недели, и круг каждую неделю сдвигается на шаг. Если рубрику выключили (например, про закрывшийся ресторан), день пропускается, фаза при этом не сдвигается, и остальные рубрики стоят на своих местах.
# app/makhall_hotel_plan.py
while cur < end:
_dn = (cur - _ANCHOR).days
# недельный сдвиг фазы = антизеркало
rub = hw.ROTATION[(_dn + _dn // 7) % len(hw.ROTATION)]
# выключенная рубрика: день пропускаем, фазу НЕ сдвигаем
if rub in disabled or _cat(rub) in disabled:
warnings.append(f"рубрика выключена тумблером: {rub}")
skipped += 1
cur += timedelta(days=1)
continue
...
Система, которая работает без присмотра, ломается не так, как ожидаешь. Каждую аварию я закрывал исправлением причины, без заплаток, и записывал в базу знаний проекта.
| Когда | Что случилось | Что изменил |
|---|---|---|
| 23.07 | Включил генерацию гостиницы вечером «чтобы канал ожил завтра». Завод сгенерил пост на «сейчас», слот уже прошёл, и за вечер в паблик ушли два несогласованных поста. | Развёл генерацию и публикацию на два разных флага. Очередь заряжается заранее отдельным вызовом, а публикатор только выпускает готовое. |
| 11.08 | Автоподхват концертов приписал артисту «RAM» организатора артиста «Ramil», потому что сравнение шло по вхождению строки. | Контрагент ставится только по точному совпадению, похожее уходит человеку подсказкой. |
| 18.08 | Шаблонный пост вышел в ленту третий раз дословно и длиннее лимита: потолок длины жил только в копирайтере. | Длина и дословный дубль за 30 дней теперь проверяются на выходе, перед площадкой, откуда бы текст ни взялся. |
| 01.09 | На сервере нашлись 16 зависших браузерных драйверов, они занимали 2,1 ГБ памяти. Каждая перезагрузка сессии Gemini запускала новый и не гасила старый. | Починил остановку драйвера и поставил сторожа, который раз в 15 минут гасит драйверы без живого браузера. |
| 01.09 | Истории гостиницы на сентябрь стояли в очереди без площадки. Не вышли бы никуда, и никто бы этого не заметил. | Исправил генератор и завёл правило: после любой массовой заливки проверять у поста площадку, медиа и парную историю. |
Соберу конвейер под ваш бизнес, от контент-плана и публикаций до броней, отзывов и рекламы. Могу и забрать соцсети целиком, буду вести их на своём заводе.