В предыдущей статье мы рассказывали, как организовали приём заказов с сайта на Tilda в СБИС Presto через webhook. Заказы стали падать в систему мгновенно. Но всплыла обратная проблема: что на сайте показывается покупателю.
Клиент — сеть общепита. Каждое утро администратор открывал СБИС, смотрел стоп-лист, потом открывал Tilda и руками выключал в каталоге всё, что в стопе. Час времени каждый день. Без выходных. И всё равно не спасало: что-то заканчивалось в обед, что-то после ужина — а кнопка «Заказать» горела до завтрашнего утра. Клиенты заказывали то, чего на кухне уже не было, менеджеры обзванивали и извинялись.
Мы написали сервис, который сам забирает стоп-лист из СБИС каждые 30 секунд и обновляет остатки в Tilda. Реакция от момента «в стоп» в СБИС до момента «нет в наличии» на сайте — около минуты. Никакого ручного переноса.
Стек: Python, FastAPI, httpx, CommerceML 2.07 (родной для Tilda обмен с 1С-системами). Месяц в продакшене.
В этой статье — архитектура, важные дизайн-решения, неочевидные грабли CommerceML и почему «синхронизировать каталог» — это плохая идея, а «синхронизировать стоп-лист» — хорошая.

Почему не «обычная» синхронизация каталога
Стандартный способ держать остатки в актуальном состоянии — выгружать весь каталог. Раз в N минут идёшь в учётную систему, забираешь полный список SKU с их остатками, пушишь в Tilda. У нашего клиента в каталоге сотни позиций. На каждый цикл тянутся килобайты данных, парсятся, переупаковываются в CommerceML, отправляются. Tilda потом всё это переиндексирует.
Заказчик предложил другую модель, и она оказалась изящнее: смотреть только на стоп-лист.
Стоп-лист в СБИС — это короткий список из нескольких десятков позиций, которые временно нельзя продавать. У нашего клиента в нём обычно 15–30 SKU ("SKU - это товар"). Логика синхронизации становится прямолинейной:
SKU в стоп-листе → в Tilda уезжает реальный физический остаток из СБИС (balance). Если осталось 5 порций — будет 5. Закончились — будет 0, кнопка погаснет.
SKU не в стоп-листе → в Tilda уезжает большое число (у нас 999999). Это «всегда доступен» — в общепите еда готовится по заказу, складских остатков нет, ограничение продаж бессмысленно.
В итоге пользователь сайта видит честную картину: и что закончилось совсем, и что заканчивается прямо сейчас («осталось 3 порции»). А мы не гоняем по сети сотни позиций ради 23 изменений.
Дополнительный плюс: стоп-лист в СБИС — это API-эндпоинт, который сам отдаёт ровно то, что нужно. Не приходится вытягивать тонну данных и фильтровать.
CommerceML — единственный канал для управления каталогом
Первое, что мы проверили — есть ли в Tilda публичный REST API для управления товарами и остатками. Нет. То, что Tilda называет «Tilda API» (api.tildacdn.info/v1/...) — это API для экспорта страниц проекта: HTML, CSS, JS, изображения. Через него можно вытащить опубликованную страницу к себе на сервер. Через него нельзя изменить ни один товар, ни один остаток, ни одну цену. Это другая задача.
Единственный официальный программный канал в каталог Tilda — CommerceML 2, родной для системы способ обмена с 1С-системами. Tilda встроила его как «контракт», по которому учётная система отдаёт каталог и остатки. Это и плюс, и обязательство:
Плюс: CommerceML — стабильный стандарт. Внутренние изменения движка Tilda не ломают приём CommerceML, потому что это публичный контракт. Это не «внутренняя API-фича, которую завтра удалят».
Обязательство: придётся аккуратно реализовать клиента CommerceML самостоятельно. Это не «отправил JSON, получил 200» — это многошаговый stateful обмен с собственной семантикой. Подробно разбираем ниже.
Что готовое мы тоже посмотрели:
Встроенный CommerceML-коннектор в самой СБИС. Это был первый подход, который мы попробовали — у СБИС есть штатная функция «синхронизировать каталог с интернет-магазином» через CommerceML. На бумаге звучит идеально: подключил Tilda, нажал кнопку, СБИС сам пушит обновления. На практике он у нашего клиента синхронизировал каталог только раз в 15 минут, делал это нестабильно (то срабатывало, то нет, без явной причины), и не давал никаких настроек. В итоге мы его отключили и написали свой пайплайн, потому что для горячего стоп-листа в общепите 15 минут лага неприемлемы, а нестабильность — тем более.
Так что — свой сервис. Зато в одном процессе, под нашим контролем, с правильной семантикой под Presto.
Архитектура
СБИС Presto → FastAPI (фоновый шедулер) → Tilda CommerceML
Сервис на одном VPS, один процесс uvicorn --workers 1 (мультиворкерность тут специально не нужна — об этом ниже), фоновая задача стартует через FastAPI lifespan:
@contextlib.asynccontextmanager async def lifespan(_app: FastAPI): task = asyncio.create_task(stock_sync_loop(), name="stock_sync_loop") logger.info("Stock sync background task spawned") try: yield finally: task.cancel() with contextlib.suppress(asyncio.CancelledError): await task
Сам цикл шедулера простой:
async def stock_sync_loop() -> None: # небольшой стартовый jitter — чтобы не дёргать СБИС в первую же миллисекунду старта await asyncio.sleep(5) try: while True: await _run_one_cycle() await asyncio.sleep(interval) except asyncio.CancelledError: logger.info("Stock sync scheduler stopped") raise
runone_cycle() инкапсулирует всю логику одного «тика»: тянет стоп-лист, тянет номенклатуру, считает что обновилось, и если есть смысл — пушит в Tilda.
Детекция изменений: сигнатура SHA-256
Самый важный кусок, без которого сервис превратился бы в DDoS Tilda — это дебаунс. Цель: не пушить в Tilda, если ничего не изменилось.
Простой и надёжный способ — хэшировать «бизнес-составляющую» снапшота и сравнивать с прошлым:
def stocks_signature(items: list[StockItem]) -> str: """Сигнатура НАБОРА остатков (без datetime) для сравнения "что изменилось".""" h = hashlib.sha256() # сортируем для детерминированности for it in sorted(items, key=lambda x: x.sku): qty_token = "inf" if it.quantity is None else str(it.quantity) h.update(f"{it.sku}|{qty_token}|{it.price:.2f}|{it.currency}\n".encode("utf-8")) return h.hexdigest()
Ключевые моменты:
Сортировка по SKU — гарантирует одинаковый хэш для одинакового набора независимо от порядка в ответе СБИС.
Только бизнес-поля — sku, qty, price, currency. Никаких таймстампов, ID пакетов, служебных колонок. Они меняются каждый тик и дали бы ложные diff'ы.
%.2f на цене — нормализуем 100 и 100.0 к одному виду.
В каждом цикле сравниваем свежую сигнатуру с прошлой успешной:
sig = stocks_signature(items) sig_changed = sig != _state["last_signature"] if not sig_changed: # ничего не изменилось Tilda не дёргаем return # дальше — формирование CommerceML и пуш
За сутки шедулер делает 2 цикла в минуту × 60 минут × 24 часа = 2880 циклов. Из них реальный пуш в Tilda происходит только тогда, когда стоп-лист в СБИС действительно изменился сколько именно раз в день это случается, зависит от загруженности в общепите. Остальные циклы — это if sig == last_sig: return за несколько миллисекунд: запрос в СБИС, хэш, сравнение, выход. Tilda этих циклов не видит.
Force-push: safety-net против рассинхрона
Diff-логика работает, но у неё есть тонкий failure mode: что если из-за бага мы что-то пропустим, или Tilda сама уронит товар из каталога? Наш state["lastsignature"] будет согласован с нашим внутренним представлением, но не с реальным состоянием Tilda.
Защита — раз в N циклов делать пуш безусловно, даже если сигнатура не изменилась:
force_push = ( _state["cycles_since_push"] >= settings.STOCK_SYNC_FORCE_PUSH_EVERY_N_CYCLES # 20 ) if not sig_changed and not force_push: _state["cycles_since_push"] += 1 return # ... дальше пуш if ok: state["cyclessince_push"] = 0 # сброс счётчика
20 циклов × 30 секунд = раз в 10 минут гарантированно пушим полный снапшот. Это устраняет любой возможный «тихий рассинхрон» с Tilda за плюс-минус разумное время. Цена — один лишний пуш CommerceML в 10 минут, что для 167 товаров — это ~80 КБ XML. Несущественно.
CommerceML протокол: 6 шагов в одной HTTP-сессии
Здесь живёт настоящая сложность. CommerceML 2 — это не «один запрос с XML», это многошаговый stateful обмен через HTTP с парой важных подводных камней.
Tilda документирует на своей стороне порядок шагов:
1. Авторизация (?type=catalog&mode=checkauth) 2. Инициализация (?type=catalog&mode=init) 3. Отправка файла import.xml (?type=catalog&mode=file& filename=import0_1.xml) 4. Отправка файла offers.xml (?type=catalog&mode=file& filename=offers0_1.xml) 5. Импорт товаров (?type=catalog& mode=import&filename=import0_1.xml) 6. Импорт остатков (?type=catalog& mode=import&filename=offers0_1.xml)
Все шаги — в одной HTTP-сессии, потому что аутентификация на шаге 1 возвращает cookie, который нужен для остальных. httpx.AsyncClient это делает автоматически.
Файлов два, и они разные:
import.xml — описание каталога: товары, их Ид, артикулы, наименования. Без этого Tilda не знает, к чему привязывать остатки.
offers.xml — пакет предложений: цены и остатки для тех же товаров. Это и есть то, что мы реально обновляем.
Авторизация — HTTP Basic, креды генерирует сама Tilda в UI: Каталог проекта → ••• → Синхронизация через CommerceML.
Подводный камень №1: статус операции — в теле ответа, а не только в HTTP-коде
В протоколе 1С-обмена, на котором построен CommerceML, статус операции принято передавать в первой строке тела ответа — success, progress или failure. Это видно уже на первом шаге: mode=checkauth отвечает телом вида success\n<имя_cookie>\n<значение>. Статус success/progress мы наблюдаем в проде постоянно — push работает, polling ловит progress перед финальным success.
Отсюда практическое следствие: нельзя полагаться только на HTTP-код. Мы не проверяли в бою, с каким именно кодом Tilda отдаёт failure (и приходил ли он вообще) — поэтому защищаемся с обеих сторон, не делая предположений о её поведении:
транспортный уровень —
raise_for_status()ловит любые не-200 ответы (сеть, 5xx, ошибки авторизации);уровень бизнес-логики — читаем первую строку тела:
success → ок, переходим к следующему шагу
progress → импорт ещё идёт, повторяем тот же GET через пару секунд
что угодно ещё (failure, текст ошибки) → считаем ошибкой
Смысл двойной проверки: если Tilda сигналит проблему HTTP-кодом — поймает raise_for_status(); если кладёт её в тело (как принято в 1С-обмене) — поймает проверка первой строки. В обоих случаях мы не сохраним сигнатуру как успешную и повторим на следующем цикле. Цена защиты — пара строк кода, так что закрываем оба варианта, вместо того чтобы гадать, как именно ведёт себя Tilda на ошибке.
Вот как у нас выглядит парсер ответа:
def _first_line(text: str) -> str: if not text: return "" return text.splitlines()[0].strip() async def _upload_file(client: httpx.AsyncClient, filename: str, body: bytes) -> bool: r = await client.post( settings.TILDA_CML_URL, params={"type": "catalog", "mode": "file", "filename": filename}, content=body, headers={"Content-Type": "application/octet-stream"}, ) r.raise_for_status() # это поймает сетевые/серверные ошибки if _first_line(r.text) != "success": logger.error("Tilda CML file upload (%s) failed: %s", filename, r.text[:500]) return False return True
raise_for_status() закрывает транспортный уровень (сеть, не-200), а проверка первой строки тела — уровень бизнес-логики. Так мы не зависим от предположений о том, каким способом Tilda сигналит конкретную ошибку.
Подводный камень №2: progress polling
Шаги 5 и 6 — mode=import — асинхронные на стороне Tilda. Она обрабатывает загруженный файл не мгновенно: в ответ на команду «импортируй» она может несколько раз подряд ответить progress («ещё в процессе, спроси позже»). Клиент обязан опрашивать тот же URL, пока не придёт финальный success или ошибка — иначе мы просто не узнаем, чем закончился импорт:
async def _process_import(client: httpx.AsyncClient, filename: str) -> bool: deadline = asyncio.get_running_loop().time() + _IMPORT_DEADLINE_SEC # 300 сек while True: r = await client.get( settings.TILDA_CML_URL, params={"type": "catalog", "mode": "import", "filename": filename}, ) r.raise_for_status() head = _first_line(r.text) if head == "success": return True if head == "progress": if asyncio.get_running_loop().time() > deadline: logger.error( "Tilda CML import timeout (>%ds) for %s", _IMPORT_DEADLINE_SEC, filename, ) return False await asyncio.sleep(3) continue logger.error("Tilda CML import (%s) failed: %s", filename, r.text[:500]) return False
На практике success приходит со 2–3-й итерации, через 6–9 секунд. Таймаут на весь polling (5 минут) — просто страховка, чтобы не ждать вечно, если Tilda по какой-то причине зависнет; деталь реализации, не более.
User-Agent
В заголовках мы отправляем User-Agent: 1C+Enterprise/8.3:
USERAGENT = "1C+Enterprise/8.3"
Это не магическая константа. Логика страховочная: Tilda CommerceML-коннектор спроектирован под пуш из 1С, и мы не хотели проверять на проде, что произойдёт, если запрос придёт с user-agent'ом python-httpx/0.x. Возможно, Tilda обработает его как обычный, возможно — заблокирует или ограничит. Мы это не тестировали через A/B, и наш совет: если делаете аналогичный сервис — поставьте этот заголовок сразу, как мы, и не выясняйте опытным путём, что без него не так. Стоимость — одна строка кода, риск отказа — нулевой.
CommerceML это полный снапшот, а не дельта
В стандарте CommerceML 2.07+ можно отправлять инкрементальные пакеты с атрибутом СодержитТолькоИзменения="true". То есть в принципе можно слать в Tilda только то, что изменилось.
Мы это не делаем. Сознательно.
<Каталог СодержитТолькоИзменения="false"> ...
Причины:
Tilda merge'ит по <Ид> (наш External ID). Каждый пуш — это «вот тебе полный снапшот; чего нет — оставь как есть, что есть — обнови». Не нужно следить, что мы отправили в прошлый раз. Делает push идемпотентным.
Идемпотентность даёт eventual consistency бесплатно. Об этом ниже.
Полный снапшот для 167 SKU — это ~80 КБ. Цена нулевая.
Атрибут СодержитТолькоИзменения="true" мы эмпирически не проверяли — есть ненулевая вероятность что Tilda обнулит товары, которых нет в дельте. Не хочется чинить в проде.
Eventual consistency без outbox
Классическая проблема: что если процесс упадёт между обновлением своего стейта и пушем в Tilda? Или Tilda недоступна, пуш провалился?
В типичных архитектурах это решается через outbox-таблицу, очередь, БД с транзакциями. У нас этого нет. Зато есть простое правило, которое снимает 95% сложности:
СБИС — единственный source of truth. Наш last_signature — это кэш, который можно потерять.
Из этого следуют два инварианта:
Сначала пушим в Tilda. Только после push_to_tilda → True обновляем state["lastsignature"] = sig.
Если пуш провалился — last_signature остаётся старым, на следующем цикле сигнатура снова не совпадёт, попытаемся снова. Бесконечно, пока Tilda не оживёт.
ok = await push_to_tilda(import_xml, offers_xml) if ok: _state["last_signature"] = sig _state["cycles_since_push"] = 0 # сброс счётчика logger.warning( "Stock sync: push НЕ УДАЛСЯ, повторим в следующем цикле ")
Что произойдёт при крашах:
Push провалился (сеть, 5xx, failure в теле) → last_signature не обновлён → следующий цикл попробует тот же снапшот. Tilda оживёт — попадём.
Процесс упал после пуша, но до апдейта стейта → после рестарта last_signature = None → весь стоп-лист «новый» → пуш повторится с тем же снапшотом. CommerceML идемпотентен — Tilda приведёт каталог в то же состояние. Семантически — никаких побочных эффектов.
Процесс упал во время пуша → не знаем, дошёл он до Tilda или нет. После рестарта — повтор. Опять же, благодаря идемпотентности — без побочек.
Не нужны очереди, не нужна БД, не нужен outbox. Хватает one process + one in-memory dict + правильного порядка операций.
Force-push раз в 10 минут (см. выше) добавляет последнюю страховку: даже если каким-то чудом наш стейт согласован, а Tilda — нет, через ≤10 минут это починится.
Параллелизм: lock и один worker
В fastapi-сервисах часто хочется крутить несколько uvicorn-воркеров. У нас сознательно один:
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
Причина: фоновый шедулер живёт в lifespan. Если воркеров два, шедулер стартует в каждом независимо. Получаем два параллельных опроса СБИС, два пуша в Tilda с возможным состоянием гонки. Никаких преимуществ для нагрузочной способности это не даст — реальная нагрузка на сервис от webhook'ов с заказов измеряется единицами запросов в минуту, нам не нужен мультиворкер.
В дополнение, на случай если manual endpoint POST /stocks/sync вызовется в момент, когда шедулерный пуш уже идёт — есть asyncio.Lock:
pushlock = asyncio.Lock() async def push_to_tilda(...) -> bool: if pushlock.locked(): logger.warning("Tilda CML push: previous push still in progress, skipping cycle") return False async with pushlock: # вся 6-шаговая логика ...
Второй вызов мгновенно возвращает False вместо того, чтобы дублировать сессию с Tilda.
Неочевидная грабля: Tilda и «бесконечность»
Tilda не понимает «бесконечный остаток» через CommerceML. В Tilda Storefront JSON quantity="" означает «безлимит». Через CommerceML offers.xml это не выставить: проверили эмпирически — <Количество></Количество> (пустой тег), литеральные <Количество>""</Количество>, или вообще отсутствие тега — все варианты Tilda сводит к 0. Поэтому эмулируем «бесконечность» большим числом 999999 для товаров не в стопе. Это вынесено в SABY_STOPLIST_INFINITE_QTY в .env — на случай если Tilda когда-нибудь поправит парсер.
Что в итоге
Один процесс на VPS, ~80 МБ RAM, без БД. Шедулер крутит цикл каждые 30 секунд, пушит в Tilda только когда есть реальные изменения остатков, гарантированно перепушивает раз в 10 минут.
Числа на боевом каталоге: ~211 SKU в СБИС из 5 прайс-листов, 23 в стоп-листе в среднем, 167 после фильтра по маппингу с External ID Tilda. Один полный CommerceML push — ~80 КБ, 4–8 секунд на 6 шагов (включая 1 раунд progress polling от Tilda).
Час ручной работы по утрам — ушёл. Между моментом «в стоп» в СБИС и моментом «нет в наличии» на сайте — ≤30 секунд + время CommerceML push. Звонков с извинениями за невыполненные заказы — почти не осталось.
Месяц в продакшене. Тихо работает.
Если у вас Tilda + СБИС Presto и похожая задача — пишите в комментариях, разберём. Особенно интересны истории тех, кто работал с CommerceML — там ещё есть углы (например, поведение Tilda на инкрементальных пакетах через СодержитТолькоИзменения="true"), в которые мы сознательно не заходили.
Автор — Алексей Громов, fullstack-разработчик, Hive Studio.
