Я веду digital в проекте, где под заказ везут мебель и интерьеры из Китая. Средний чек около 30 тысяч долларов, цикл сделки два-три месяца. Один из каналов прогрева это Telegram Ads: человек видит объявление в тематическом канале, подписывается на канал проекта, дальше его греет контент, и через недели или месяцы он приходит с запросом.
За 22 месяца через этот кабинет прошло около 15-20 тысяч долларов медиабюджета, 1 043 канала в тесте, 3 000 креативов и примерно 19 650 связок “канал плюс креатив”. Платных подписок из рекламы за это время 6 073. Сам канал за период вырос с 2,2 тысяч до 24 тысяч подписчиков, но это уже вместе с органикой и контентом, реклама тут не единственный источник, и я это разделяю.
Дальше не про маркетинг. Дальше про то, как я обвешал этот кабинет скриптами, что из этого сработало, и как система из автоправил однажды начала гасить сама себя, а я две недели не мог понять, почему объём просел на ровном месте.
Про соседнюю аварию в этой же системе, когда сторож бюджета отработал ровно как написан и именно поэтому пропустил перерасход, я уже писал отдельно. Здесь другая история: там ошибка была внутри одного скрипта, тут она между скриптами, и это оказалось намного труднее увидеть.
На чём вообще считаем
Одна метрика: сколько стоит подписчик в канал. Планка 1,5 евро. Всё, что дороже, либо чинится, либо выключается.
Это важное упрощение, и я о нём скажу сразу, потому что дальше вся логика на нём висит. Подписчик это не сделка. Между подпиской и деньгами лежит контент, время и отдел продаж. Я оптимизирую промежуточную метрику, потому что до сделки в этом канале два-три месяца, и по ней невозможно управлять ставками в реальном времени. Раз в квартал я сверяю по CRM, что подписчики вообще доходят до сделок, и если связь ломается, планка пересматривается. Без этой сверки вся конструкция превращается в оптимизацию ради цифры в кабинете.
Второе, что надо знать про площадку. Ты платишь за показы, ставка задаётся как CPM, и она решает, покажут тебя в конкретном канале или нет. Никакой оптимизации под конверсию на стороне площадки нет. Есть ты, ставка и канал. Всё, что напоминает умные стратегии, ты пишешь сам.
Отсюда весь объём ручной работы. По каждой связке надо: понять, живая ли аудитория, поставить стартовую ставку, дождаться показов, посмотреть цену подписчика, поднять ставку или выключить. Пока связок десять, это вечер. Когда их тысяча, ты сидишь в кабинете по четыре часа в день и всё равно не успеваешь.
Откуда берутся каналы
Первая задача чисто инженерная: получить список доступных для размещения каналов. У площадки это отдельный справочник, он большой, страничный и постоянно меняется. Забирать его руками бессмысленно, я забираю три раза в неделю скриптом.
Главная засада здесь не в самом запросе, а в пагинации. Первый вариант скрипта тянул только первую страницу, и я месяц работал с обрезанным справочником, не зная об этом. Понял, только когда сверил число каналов в выгрузке с числом в интерфейсе.
// harvest.mjs забираем справочник каналов постранично const PAGES = 5; // эмпирика: дальше пятой страницы отдаёт пусто const out = new Map(); for (let page = 1; page <= PAGES; page++) { const res = await api.get('/channels', { params: { page, per_page: 200 } }); const items = res.data?.items ?? []; if (!items.length) break; // страницы кончились раньше лимита for (const ch of items) { // ключ по username, а не по внутреннему id: id у площадки переиспользуются out.set(ch.username, { username: ch.username, title: ch.title, subs: ch.subscribers_count, views: ch.avg_post_views, // средний охват поста lang: ch.language, topics: ch.topics ?? [], }); } await sleep(1200); // без паузы прилетает 429 }
Две вещи, на которых я обжёгся и которые здесь зашиты.
Ключ по username, а не по внутреннему идентификатору. Идентификаторы у площадки переиспользуются, и при склейке выгрузок за разные недели я получал каналы-химеры: имя от одного, статистика от другого. Диагностируется это отвратительно, потому что данные выглядят правдоподобно.
Пауза между страницами. Без неё на третьей странице стабильно прилетает 429, скрипт падает, крон считает запуск успешным, и ты опять работаешь с обрезанным справочником. Молчаливая деградация хуже явной ошибки: она не заметна, пока не сверишь руками. С тех пор у меня правило: любой сборщик данных в конце пишет в лог, сколько строк он собрал, а отдельная проверка сравнивает это число со вчерашним и ругается, если оно упало больше чем на треть.
Как отсеиваю мусор: LLM со строгой схемой
В справочнике полно каналов, которые технически подходят, а по смыслу нет. Детские, накрученные, региональные не про то. Раньше я смотрел их глазами, и это самая тупая часть работы.
Сейчас первый проход делает языковая модель. Важный момент: она не принимает решение. Она сокращает 300 каналов до 30, по которым думаю я.
Ключевое в этом коде не промпт, а то, что ответ загнан в жёсткую схему и валидируется. Первая версия просила “оцени релевантность и объясни”, модель отвечала прозой, я парсил регулярками, и на каждой двадцатой карточке парсер ломался.
// score.mjs скоринг канала под продукт const schema = { type: 'object', required: ['score', 'audience', 'reason'], properties: { score: { type: 'integer', minimum: 0, maximum: 10 }, audience: { type: 'string', enum: ['b2c_premium','b2c_mass','b2b','unclear'] }, reason: { type: 'string', maxLength: 200 }, }, }; async function scoreChannel(ch, product) { const r = await llm.chat({ temperature: 0, // нужна воспроизводимость, не креатив response_format: { type: 'json_schema', json_schema: { schema, strict: true } }, messages: [ { role: 'system', content: 'Ты оцениваешь рекламные площадки. Отвечай только по схеме. ' + 'Если данных не хватает, ставь score 0 и audience unclear.' }, { role: 'user', content: JSON.stringify({ product, // что продаём, кому, какой чек channel: { title: ch.title, subs: ch.subs, views: ch.views, topics: ch.topics, sample_posts: ch.lastPosts.slice(0, 5), }, }) }, ], }); const v = JSON.parse(r.choices[0].message.content); // страховка от уверенной чуши: мало охвата, значит доверия к оценке нет if (ch.views < 300) v.score = Math.min(v.score, 4); return v; }
Строчка про views < 300 появилась после того, как модель поставила 9 из 10 каналу с полутора тысячами подписчиков и охватом поста в 40 просмотров. По описанию канал был идеальный. По факту его никто не читал. Модель судит по тексту, а текст пишет владелец канала, и он заинтересован.
Что даёт скоринг честно: он экономит часы на отсеве очевидного мусора. Он не даёт предсказать, выстрелит канал или нет. Попадание после ручной проверки у меня около 15 процентов, то есть из ста протестированных каналов рабочими становятся пятнадцать. До скоринга доля была примерно та же, но перебирать приходилось втрое больше.
Отсюда простое следствие, которое я бы держал в голове в любой задаче со скорингом через модель: если ваша модель улучшает не долю попаданий, а скорость перебора, это нормальный результат, просто не путайте его с предсказанием.
Жизненный цикл объявления как конечный автомат
Прежде чем про ставки, надо описать состояния. Долгое время у меня их не было явно, скрипты просто дёргали объявления по условиям, и именно из-за этого случилась главная авария.
Сейчас у объявления пять состояний.
Состояние |
Что это |
Как выходим |
|---|---|---|
|
новое, ставка ниже рынка, показов ноль |
набрало показы, идёт в |
|
ставка растёт ступеньками до первых показов |
набрало 300 показов, идёт в |
|
накопили статистику, считаем цену подписчика |
сходится, идёт в |
|
работает в плюс, ставку не трогаем |
цена ушла выше планки, возврат в |
|
выключено с зафиксированной причиной |
руками или через воскрешение спящих |

Главное правило, которое я вывел кровью: из каждого состояния должен быть выход и по успеху, и по провалу, и по времени. Состояние без выхода по таймауту это ловушка, в которую объекты падают и копятся молча.
Ставка: степпер вместо угадывания
Ставка в Telegram Ads это порог, до которого тебя не показывают вообще. Поставил мало, объявление лежит с нулём показов. Поставил много, показы пойдут, но подписчик выйдет дороже планки.
Правильного значения не знает никто, оно зависит от того, кто ещё сейчас торгуется за этот канал. Поэтому я не угадываю, а щупаю: ставлю заведомо низко и поднимаю ступеньками, пока не появятся показы.
// stepper.mjs подъём CPM до первых показов const STEP = 0.15; // евро за шаг const CEIL = 3.20; // потолок, выше не идём никогда const GRACE = 45; // минут ждать после изменения ставки async function step(ad) { const st = await stats(ad.id, { window: '2h' }); if (st.impressions >= 300) return 'ok'; // разогнался, не трогаем if (minutesSince(ad.cpm_changed_at) < GRACE) return 'wait'; // не дёргаем чаще if (ad.cpm >= CEIL) return kill(ad, 'ceiling'); // дороже смысла нет const next = round2(Math.min(ad.cpm + STEP, CEIL)); await durablePatch(ad.id, { cpm: next }); // почему durable, см. ниже return `cpm ${ad.cpm} to ${next}`; }
Про GRACE в 45 минут. Первая версия проверяла раз в пять минут и поднимала ставку, если показов нет. Выглядело разумно. По факту площадка отдаёт статистику с задержкой, и скрипт поднимал ставку четыре раза подряд, не увидев показов, которые уже были. За вечер он загнал несколько объявлений с 0,8 до 2,6 евро на ровном месте. Задержка статистики это не баг площадки, это её свойство, и автоматика обязана про него знать. Любой цикл вида “посмотрел, не увидел эффекта, добавил ещё” обязан знать, через сколько эффект вообще может появиться.
Про потолок CEIL. Он не про экономику, он про защиту от собственной логики. Цикл “не получилось, добавь ещё” без жёсткого верхнего предела рано или поздно упрётся в кошелёк, а не в здравый смысл.
Мёртвая зона, которую я нашёл случайно
Отдельная находка, ради которой стоило вести логи. Объявления, набравшие от 250 до 299 показов, вели себя странно: они не разгонялись дальше, но и не выглядели проигравшими. Порог перехода в judge стоял на 300 показах, и всё, что чуть-чуть не дотянуло, зависало навсегда: степпер их уже не трогал, потому что условие impressions >= 300 не выполнялось, но и гейт на выключение не срабатывал, потому что судить было формально рано.
Таких зависших объявлений накопилось несколько десятков. Они не тратили заметных денег и поэтому не попадали ни в один алерт. Они просто занимали слоты и портили статистику.
Лечится дотестом: всё, что застряло между 250 и 299 показами дольше суток, принудительно получает вердикт. Это ровно тот случай, который я потом вынес в правило про выход по таймауту из каждого состояния.
Когда выключать: гейт по прогнозной цене
Второй скрипт решает обратную задачу: не разгонять, а вовремя остановить. Ждать фактической цены подписчика долго, поэтому считаю прогнозную и сравниваю с планкой.
// guard.mjs отключаем то, что не сходится по экономике const TARGET = 1.5; // евро за подписчика, планка проекта const MIN_IMPR = 300; // раньше судить статистически не о чем function projectedCPA(ad, st) { if (st.subs > 0) return st.spend / st.subs; // есть факт, берём факт if (st.impressions < MIN_IMPR) return null; // рано судить // подписок нет: считаем так, будто следующая случится прямо сейчас return st.spend / 1; } async function guard(ad) { const st = await stats(ad.id, { window: '24h' }); if (!sane(st)) return 'skip: данные не похожи на правду'; const cpa = projectedCPA(ad, st); if (cpa === null) return 'young'; if (cpa > TARGET * 2) return kill(ad, `cpa ${cpa.toFixed(2)} выше двух планок`); if (cpa > TARGET) return lowerCpm(ad, 0.15); return 'ok'; }
Тут важна строчка st.spend / 1. Пока подписок ноль, честной цены не существует, это деление на ноль. Соблазн подставить бесконечность и всё выключить. Соблазн подставить ноль и не выключать ничего. Я считаю так, будто подписка вот-вот случится: это самая мягкая из пессимистичных оценок, она не рубит сгоряча молодые размещения и при этом гарантированно сработает, если объявление продолжит есть бюджет впустую.
Функция sane тут не для красоты. Раз в несколько недель отчёт по деньгам приходит пустым или частичным. Если скрипт примет это за “расход ноль”, он радостно решит, что всё сходится. Поэтому любой расчёт, где участвуют деньги или конверсии, сначала проверяет, что данные вообще правдоподобны: суммы не нулевые, число строк не упало вдвое относительно вчера. Не сошлось, значит ничего не делаем и пишем в алерт.
Каннибализм: когда торгуешься сам с собой
Ещё одна неочевидная вещь. В одном канале может крутиться несколько твоих объявлений одновременно: разные креативы, разные тексты. Кажется, что это просто больше тестов. По факту они конкурируют между собой на одном и том же аукционе и поднимают цену друг другу.
Заметно это не сразу, потому что каждое объявление по отдельности выглядит нормально. Видно только в срезе по каналу.
// dedup.mjs оставляем в канале одно объявление за раз const rows = await db.all(` SELECT channel, COUNT(*) AS live FROM ads WHERE state IN ('probe','ramp','judge','run') GROUP BY channel HAVING live > 1 `); for (const r of rows) { const ads = await adsOfChannel(r.channel); // оставляем самое зрелое: больше показов, значит меньше терять ads.sort((a, b) => b.impressions - a.impressions); for (const extra of ads.slice(1)) { await pause(extra.id, `каннибализм в ${r.channel}`); } }
Правило простое: один канал, одно живое объявление. Хочешь протестировать второй креатив, дождись вердикта по первому. Это снижает скорость перебора и повышает его качество, потому что результат теста перестаёт зависеть от того, что ты сам же и делаешь рядом.
Как правила начали воевать друг с другом
Теперь про то, ради чего я это пишу.
Скриптов стало много. Один поднимает ставку, если нет показов. Второй снижает её, если цена подписчика выше планки. Третий будит каналы, которые долго молчали. Четвёртый гасит дубли. Каждый по отдельности логичный и протестированный.
В какой-то момент объём подписок просел примерно на треть без видимой причины. Бюджет тот же, каналы те же, креативы те же. Я две недели искал причину снаружи: сезон, конкуренты, выгорание креативов, изменения на площадке.
Причина была внутри. Степпер поднимал ставку объявлению, у которого не было показов. Через сорок пять минут гвард видел то же объявление, у которого показов всё ещё нет, но появилось одно списание, считал прогнозную цену запредельной и ронял ставку обратно. Ещё через сорок пять минут степпер видел объявление без показов и поднимал снова.
Объявление колебалось между двумя значениями ставки сутками. Показов оно не набирало ни в одном из положений, потому что каждое изменение CPM сбрасывает разгон, и площадке нужно время, чтобы снова начать показывать. Ни один из скриптов при этом не считал, что что-то не так: оба отрабатывали свою логику успешно и писали в лог успех.
Нашёл я это не по алерту, а когда полез в историю изменений и увидел пилу.
// conflict-scan.mjs ищем объявления, которыми управляют двое const WINDOW_H = 12; const rows = await db.all(` SELECT ad_id, COUNT(*) AS changes, COUNT(DISTINCT actor) AS actors, MAX(cpm) - MIN(cpm) AS spread FROM cpm_log WHERE changed_at > datetime('now', '-${WINDOW_H} hours') GROUP BY ad_id HAVING changes >= 4 AND actors >= 2 `); for (const r of rows) { // пила: много правок, разные авторы, а размах ставки маленький if (r.spread > 0.6) continue; // это не пила, это осмысленный разгон console.warn(`ad ${r.ad_id}: ${r.changes} правок от ${r.actors} скриптов, размах ${r.spread}`); await freeze(r.ad_id, '6h'); // замораживаем и разбираемся руками }

Что я поменял по существу, а не косметически.
Во-первых, у каждого объявления теперь один владелец. Скрипт, который взял объявление в работу, помечает его своим именем, и остальные его не трогают до снятия метки. Это скучно и это работает лучше любой умной координации.
Во-вторых, любое изменение ставки пишется в лог с автором и причиной. Без этого лога я бы искал причину до сих пор, потому что в интерфейсе площадки видно текущее значение, а не историю того, кто и зачем его менял.
В-третьих, конфликт теперь ловится сам. Четыре правки за 12 часов от двух разных скриптов при малом размахе ставки это по определению пила, а не работа. Условие про spread появилось после первых ложных срабатываний: нормальный разгон тоже даёт много правок, но у него ставка едет в одну сторону, а не топчется.
Общий вывод шире, чем про рекламу. Набор простых правил, каждое из которых корректно, не является корректной системой. Ошибка живёт не внутри правил, а между ними, и увидеть её можно только в логе действий, а не в состоянии объекта. Состояние отвечает на вопрос “как сейчас”. Оно не отвечает на вопрос “как мы сюда попали”, а именно там и прячется баг.
Ещё одна грабля: тихо непринятый PATCH
Площадка иногда принимает запрос на смену ставки и не применяет его. Ответ 200, тело в порядке, значение прежнее. Пока я этого не знал, часть объявлений жила со ставкой, которую я считал изменённой, и я делал выводы про рынок на основе того, чего не происходило.
// durable.mjs применили, проверили, повторили async function durablePatch(adId, patch, tries = 3) { for (let i = 1; i <= tries; i++) { await api.patch(`/ads/${adId}`, patch); await sleep(60_000); // даём применить const fresh = await api.get(`/ads/${adId}`); const ok = Object.entries(patch) .every(([k, v]) => Math.abs(fresh.data[k] - v) < 1e-6); if (ok) return true; log.warn(`ad ${adId}: попытка ${i}, значение не применилось`); } await alert(`ad ${adId}: patch не применился ${tries} раза`); return false; }
Здесь нет ничего изобретательного, но это ровно тот код, который отделяет “я управляю системой” от “я думаю, что управляю системой”. Если внешний API не даёт гарантии применения, гарантию делаешь ты, и делаешь её чтением после записи.
Что показала статистика победителей
Когда накопилась история, я посмотрел на то, что вообще попадает в разряд рабочих размещений. Рабочими я считаю каналы, где цена подписки держится ниже двух евро на дистанции.
Таких каналов у меня набралось 46. Две вещи в их профиле оказались против интуиции.

Первая: баннерные форматы дают почти половину рабочих размещений, 22 из 46, хотя по объёму запусков посты шли заметно впереди. Вывод чисто практический: на новом канале первым пробую баннер, а не пост.
Вторая: у рабочих каналов маленький охват. Медиана около 800 просмотров на пост, типичный диапазон от 500 до 1 500. Крупные каналы с охватом в 6-17 тысяч в разряд рабочих почти не попадали: там дороже аукцион и размытее аудитория. Это ломает привычную логику “берём каналы побольше” и означает, что искать надо в среднем и мелком сегменте, где конкуренция за показ ниже.
Я специально не выношу это как рекомендацию для всех. Это статистика одного продукта в одной нише за 22 месяца. Но методологически вывод общий: прежде чем оптимизировать ставки, посмотрите на профиль тех размещений, которые у вас уже работают. Он может не совпасть с тем, куда вы льёте объём.
Зачем я бужу мёртвые каналы
Из 1 043 протестированных каналов основная масса в какой-то момент оказалась выключена. Часть из них выключена справедливо, часть по стечению обстоятельств: неудачный креатив, неудачная неделя, конкурент, который в тот момент выкупал показы.
Поэтому у меня есть отдельный цикл, который раз в месяц берёт выключенные каналы, отбирает те, что умерли не по экономике, а по формальному признаку, и даёт им ещё один шанс с новым креативом.
// revive.mjs второй шанс для тех, кого выключили не за деньги const candidates = await db.all(` SELECT channel, reason, killed_at, impressions, spend FROM ads WHERE state = 'dead' AND killed_at < datetime('now','-30 days') AND reason NOT IN ('cpa_2x', 'ceiling') -- эти умерли за дело AND impressions < 500 -- эти толком и не тестировались ORDER BY killed_at ASC LIMIT 40 `);
Смысл в том, что “выключено” в автоматической системе означает две очень разные вещи: “проверено и не сходится” и “не доехало до проверки”. Первое трогать не надо. Второе это ваш неиспользованный запас, и он копится тем быстрее, чем агрессивнее ваши правила отключения.
Что автоматизация дала и чего не дала
Что |
До |
После |
|---|---|---|
Время в кабинете |
около 4 часов в день |
около 30 минут на разбор алертов |
Объявлений в тесте одновременно |
десятки |
сотни |
Цена подписчика |
планка 1,5 евро |
планка 1,5 евро |
Каналов протестировано за период |
сотни |
1 043 |
Причина отключения размещения |
“кажется, не идёт” |
зафиксированное правило и запись в логе |
Главное в этой таблице то, чего в ней нет. Цена подписчика не изменилась. Я не купил автоматизацией снижение цены. Я купил объём тестов и время при той же экономике, а дешёвые связки вытаскивает уже объём.
Это скучный вывод, и он единственный, который подтверждается цифрами. Когда мне показывают, что автоматика сама по себе снизила стоимость привлечения, я первым делом спрашиваю, что ещё менялось в тот же период. В моём случае в тот же период менялись креативы, состав каналов и правила бюджета, и честно разделить вклад я не могу. Утверждать, что дело в скриптах, было бы приятно и неправда.
Отдельно про то, чего автоматика не умеет. Она не понимает, почему цена подписчика за неделю выросла с одного евро до двух с половиной. Она умеет быстро притормозить слив, а причину (выгорел креатив, сменился состав каналов, зашёл конкурент) всё равно ищешь руками. Соотношение примерно такое: рутину она забирает целиком, диагностику не забирает вообще.
Что с этим делать, если беретесь за похожее
Порядок, в котором я бы делал это заново, а не тот, в котором делал.
Сначала лог всех изменений с автором и причиной. До первого автоправила. Без него вы не отладите ничего, потому что состояние объекта не рассказывает, как он в него попал.
Потом один владелец на объект. Даже если скрипт пока один. Второй появится, и появится он тихо, потому что его напишете вы же через полгода и забудете про первый.
Потом явные состояния и обязательный выход из каждого по таймауту. Иначе у вас заведётся своя мёртвая зона на 250 показах, и вы найдёте её случайно.
Потом жёсткие пределы: потолок ставки, потолок дневного расхода, минимальная пауза между действиями над одним объектом. Все три должны быть константами в коде, а не значениями в конфиге, который кто-то поменяет на бегу.
Потом проверка вменяемости данных на входе в любой расчёт про деньги, и чтение после записи для любого внешнего API, который не даёт гарантий.
И только потом сама логика оптимизации. Она самая простая часть и самая переоценённая.
Ещё одно наблюдение напоследок. Половину эффекта у меня дали не хитрые алгоритмы, а два скучных скрипта: тот, что раз в три дня забирает справочник каналов, и тот, что раз в сутки выключает объявления, которые давно не набирают показов. Всё остальное было интереснее писать и принесло меньше. Это, кажется, общее свойство таких систем: полезность обратно пропорциональна тому, насколько увлекательно было это делать.
Код в статье упрощён и очищен от идентификаторов кабинетов и токенов. Логика, пороги и грабли настоящие.
ajijiadduh
причём тут системное администрирование и девопс?