В предыдущей статье мы рассказывали, как организовали приём заказов с сайта на Tilda в СБИС Presto через webhook. Заказы стали падать в систему мгновенно. Но всплыла обратная проблема: что на сайте показывается покупателю.

Клиент — сеть общепита. Каждое утро администратор открывал СБИС, смотрел стоп-лист, потом открывал Tilda и руками выключал в каталоге всё, что в стопе. Час времени каждый день. Без выходных. И всё равно не спасало: что-то заканчивалось в обед, что-то после ужина — а кнопка «Заказать» горела до завтрашнего утра. Клиенты заказывали то, чего на кухне уже не было, менеджеры обзванивали и извинялись.

Мы написали сервис, который сам забирает стоп-лист из СБИС каждые 30 секунд и обновляет остатки в Tilda. Реакция от момента «в стоп» в СБИС до момента «нет в наличии» на сайте — около минуты. Никакого ручного переноса.

Стек: Python, FastAPI, httpx, CommerceML 2.07 (родной для Tilda обмен с 1С-системами). Месяц в продакшене.

В этой статье — архитектура, важные дизайн-решения, неочевидные грабли CommerceML и почему «синхронизировать каталог» — это плохая идея, а «синхронизировать стоп-лист» — хорошая.

Архитектура сервиса синхронизации СБИС Presto → Tilda CommerceML
Архитектура сервиса синхронизации СБИС Presto → Tilda 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"> ...

Причины:

  1. Tilda merge'ит по <Ид> (наш External ID). Каждый пуш — это «вот тебе полный снапшот; чего нет — оставь как есть, что есть — обнови». Не нужно следить, что мы отправили в прошлый раз. Делает push идемпотентным.

  2. Идемпотентность даёт eventual consistency бесплатно. Об этом ниже.

  3. Полный снапшот для 167 SKU — это ~80 КБ. Цена нулевая.

  4. Атрибут СодержитТолькоИзменения="true" мы эмпирически не проверяли — есть ненулевая вероятность что Tilda обнулит товары, которых нет в дельте. Не хочется чинить в проде.

Eventual consistency без outbox

Классическая проблема: что если процесс упадёт между обновлением своего стейта и пушем в Tilda? Или Tilda недоступна, пуш провалился?

В типичных архитектурах это решается через outbox-таблицу, очередь, БД с транзакциями. У нас этого нет. Зато есть простое правило, которое снимает 95% сложности:

СБИС — единственный source of truth. Наш last_signature — это кэш, который можно потерять.

Из этого следуют два инварианта:

  1. Сначала пушим в Tilda. Только после push_to_tilda → True обновляем state["lastsignature"] = sig.

  2. Если пуш провалился — 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.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
А как у вас решена синхронизация остатков с витриной?
100%Свой сервис через CommerceML1
0%Вручную / Excel-выгрузки0
0%У нас нет онлайн-витрины0
0%Готовые коннекторы (1С, Albato, и т.п.)0
Проголосовал 1 пользователь. Воздержался 1 пользователь.