Оформление
Контейнер карты спроса
Это спецификация JSON-файла для DSP, программатика, ретаргетинговой платформы и любой другой системы, которая применяет карту спроса Sales Ninja. Документ описывает wire-контракт demand-map/v1, алгоритм сопоставления и безопасное обновление файла по постоянной ссылке.
Главное правило
coefficient — множитель относительного спроса. 1.0 означает средний спрос проекта. Трафик, который не совпал ни с одним правилом, получает значение coefficient.absent, сейчас всегда 0: показывать его по этой карте не нужно.
Быстрый старт для интегратора
- Получите постоянную ссылку у клиента Sales Ninja.
- Скачайте JSON во временное хранилище, не заменяя рабочую версию сразу.
- Проверьте
schemaVersion,format, обязательные поля и коэффициенты. - Загрузите
dictionariesи подготовьте признаки показа в кодах изtaxonomy.conditionKeys. - Постройте индекс дерева или плоских строк.
- Атомарно сделайте новую версию рабочей только после полной успешной проверки.
- Для каждого показа найдите ровно одно наиболее точное правило; если его нет, используйте
coefficient.absent. - При сетевой ошибке, невалидном JSON или неизвестной версии продолжайте использовать последний успешно принятый файл.
Все значения осей вынесены в самостоятельный раздел справочников Sales Ninja. Карта спроса использует их, но не владеет ими.
Получение и обновление
Ссылка постоянна: её настраивают один раз, а Sales Ninja публикует новые версии по тому же адресу. Активная карта обычно пересчитывается ежедневно, но потребитель не должен рассчитывать на точную минуту. Источник истины о свежести — container.generatedOnUtc внутри успешно разобранного файла.
Рекомендуемый цикл:
text
download to candidate
→ parse JSON
→ validate schema and invariants
→ build lookup index
→ atomically replace current version
→ retain previous version for rollbackЕсли очередной расчёт Sales Ninja падает, по ссылке остаётся предыдущая валидная версия. Если скачивание или проверка падает у потребителя, он также обязан оставить свою предыдущую рабочую версию. Пустой ответ, HTML-страница ошибки или частично прочитанный JSON не являются новой картой.
Повторно полученный файл с тем же generatedOnUtc можно считать той же публикацией. URL следует хранить как конфигурационный секрет интеграции и не выводить в публичные логи или клиентский JavaScript без необходимости.
Полный объект верхнего уровня
| Поле | Тип | Обязательно | Значение |
|---|---|---|---|
schemaVersion | string | да | Версия всего контракта, сейчас demand-map/v1 |
format | string | да | tree/v1 или flat/v1 |
container | object | да | Время публикации и окно исходных данных |
coefficient | object | да | Семантика и границы коэффициента |
taxonomy | object | да | Оси, реально встречающиеся в этом файле |
dictionaries | object | да | Самодостаточные справочники географии и интересов |
tree | array | для tree/v1 | Корневые узлы дерева; у flat/v1 отсутствует |
segments | array | для flat/v1 | Непересекающиеся строки; у tree/v1 отсутствует |
JSON использует camelCase. Поля со значением null обычно не сериализуются, поэтому отсутствие необязательного поля и явный null нужно трактовать одинаково только там, где это разрешено ниже.
Метаданные container
json
{
"generatedOnUtc": "2026-07-31T03:12:00Z",
"sourceFromUtc": "2026-05-02T00:00:00Z",
"sourceToUtc": "2026-07-31T00:00:00Z"
}| Поле | Значение |
|---|---|
generatedOnUtc | Когда именно эта версия контейнера была собрана и опубликована |
sourceFromUtc | Начало окна данных, участвовавших в расчёте |
sourceToUtc | Конец окна данных, участвовавших в расчёте |
Все три значения — ISO 8601 в UTC. Не заменяйте sourceToUtc временем скачивания: публикация может произойти позже конца окна. Два контейнера с разными окнами нельзя сравнивать как один и тот же срез без дополнительной проверки.
Метаданные coefficient
json
{
"meaning": "relativeDemand",
"baseline": 1.0,
"absent": 0,
"max": 10.0,
"kind": "multiplier"
}| Поле | Текущее значение | Как использовать |
|---|---|---|
meaning | relativeDemand | Число сравнивает плотность спроса со средним проекта |
baseline | 1.0 | Базовая плотность спроса |
absent | 0 | Значение для любого трафика без совпавшего правила |
max | зависит от профиля партнёра | Верхняя граница, под которую была построена карта |
kind | multiplier | Коэффициент записан как множитель |
kind не является выбором потребителя. В demand-map/v1 сериализатор публикует только multiplier.
Примеры преобразования, если политика партнёра разрешает прямое умножение:
text
baseBid = 40 ₽, coefficient = 1.5 → candidateBid = 60 ₽
baseBid = 40 ₽, coefficient = 3.0 → candidateBid = 120 ₽
coefficient = 0 → показ запрещён картойЭто иллюстрация, а не требование к торгам: партнёр может использовать множитель для ранжирования, допуска или собственного ограниченного bidding-правила. Его бюджетные и правовые ограничения применяются после сопоставления.
Проверки потребителя:
- число конечно и не отрицательно;
- опубликованный коэффициент не выше
maxс учётом обычной точности десятичного JSON; baseline,absent,meaningиkindпонятны реализации;- коэффициенты по пути никогда не суммируются и не перемножаются.
Sales Ninja округляет опубликованные коэффициенты до четырёх знаков после запятой. 1.23456 в исходном расчёте выйдет как 1.2346. Не сравнивайте floating-point значения на точное равенство после собственных преобразований.
Таксономия файла
json
{
"conditionKeys": ["device", "browser", "city", "ageBand"]
}taxonomy.conditionKeys — исчерпывающий список осей, которые действительно встречаются в tree или segments этой версии. Это не список всех возможностей Sales Ninja и не список всех осей профиля партнёра.
Правила:
- ключа вне
conditionKeysв правилах быть не должно; - ключ из
conditionKeysможет встречаться не в каждой ветке или строке; - порядок ключей не является приоритетом и не заменяет структуру дерева;
- если потребитель не умеет определить один из опубликованных ключей, условие с ним не совпадает; удалять непонятное условие нельзя, иначе сегмент расширится;
- новый неизвестный ключ нельзя молча игнорировать внутри условия. Безопасный результат для такой ветки — отсутствие совпадения и диагностический сигнал.
Все текущие ключи и режимы значений: общий раздел справочников.
Справочники dictionaries
Объект всегда содержит четыре массива, даже если некоторые пусты:
json
{
"countries": [],
"regions": [],
"cities": [],
"interests": []
}В файл входят только элементы, коды которых реально использованы в опубликованных правилах. Это делает контейнер самодостаточным, но не превращает его в глобальный каталог всех стран, городов или интересов.
countries
| Поле | Тип | Назначение |
|---|---|---|
code | string | Первичный машинный ключ страны |
name | string | Основное название, если доступно |
russianName | string | Русское название, если доступно |
englishName | string | Английское название, если доступно |
nativeName | string | Название на местном языке, если доступно |
population | integer | Население, если известно |
regions
Поля страны плюс обязательная для связи строка countryCode. code уникально идентифицирует элемент в массиве, а countryCode указывает на countries[].code.
cities
Поля названия и населения плюс countryCode и regionCode. Ссылки ведут на коды соответствующих массивов. Не связывайте город с регионом по одинаковому тексту названия.
interests
| Поле | Тип | Назначение |
|---|---|---|
code | string | Стабильный leaf-код, используемый в условии interest |
name | string | Основное человекочитаемое название |
russianName | string | Русское название, если доступно |
englishName | string | Английское название, если доступно |
sourceCode | string | Непрозрачный код совместимости; не использовать для сопоставления |
Все 332 текущих кода и их фактические уровни 1–3 описаны в отдельном справочнике интересов.
Полный пример справочников
json
{
"countries": [
{
"code": "RU",
"name": "Russia",
"russianName": "Россия",
"englishName": "Russia",
"nativeName": "Россия",
"population": 146150789
}
],
"regions": [
{
"code": "moscow",
"name": "Moscow",
"russianName": "Москва",
"englishName": "Moscow",
"nativeName": "Москва",
"countryCode": "RU",
"population": 13010112
}
],
"cities": [
{
"code": "moscow-city",
"name": "Moscow",
"russianName": "Москва",
"englishName": "Moscow",
"nativeName": "Москва",
"regionCode": "moscow",
"countryCode": "RU",
"population": 13010112
}
],
"interests": [
{
"code": "city_cars",
"name": "Town cars",
"russianName": "Городские автомобили",
"englishName": "Town cars",
"sourceCode": "228"
}
]
}Значения примера иллюстративны. Для сопоставления всегда используйте словари полученного файла.
Формат tree/v1
Дерево компактно выражает общую часть условий. Полное условие узла складывается из всех пар axis=value от корня до этого узла.
Поля узла
| Поле | Тип | Обязательно | Значение |
|---|---|---|---|
axis | string | да | Один ключ из taxonomy.conditionKeys |
value | string | да | Код или нормализованное значение оси |
coefficient | number | нет | Коэффициент для остатка этого узла |
children | array | нет | Более точные дочерние условия |
Отсутствующий coefficient — не ошибка и не наследование. Он означает, что неперечисленный остаток этой ветки получает coefficient.absent. Спрос может оставаться только в совпавшем дочернем узле.
Полный пример дерева
json
{
"schemaVersion": "demand-map/v1",
"format": "tree/v1",
"container": {
"generatedOnUtc": "2026-07-31T03:12:00Z",
"sourceFromUtc": "2026-05-02T00:00:00Z",
"sourceToUtc": "2026-07-31T00:00:00Z"
},
"coefficient": {
"meaning": "relativeDemand",
"baseline": 1.0,
"absent": 0,
"max": 10.0,
"kind": "multiplier"
},
"taxonomy": {
"conditionKeys": ["device", "browser", "city", "ageBand", "interest"]
},
"dictionaries": {
"countries": [
{ "code": "RU", "name": "Russia", "russianName": "Россия" }
],
"regions": [
{ "code": "moscow", "name": "Moscow", "russianName": "Москва", "countryCode": "RU" }
],
"cities": [
{
"code": "moscow-city",
"name": "Moscow",
"russianName": "Москва",
"englishName": "Moscow",
"regionCode": "moscow",
"countryCode": "RU"
}
],
"interests": [
{
"code": "city_cars",
"name": "Town cars",
"russianName": "Городские автомобили",
"englishName": "Town cars",
"sourceCode": "228"
}
]
},
"tree": [
{
"axis": "device",
"value": "desktop",
"coefficient": 1.35,
"children": [
{
"axis": "browser",
"value": "safari",
"coefficient": 4.82
},
{
"axis": "browser",
"value": "outdated_browser"
}
]
},
{
"axis": "device",
"value": "smartphone",
"children": [
{
"axis": "city",
"value": "moscow-city",
"children": [
{
"axis": "ageBand",
"value": "25-34",
"coefficient": 1.6
}
]
}
]
},
{
"axis": "device",
"value": "tablet",
"children": [
{
"axis": "interest",
"value": "city_cars",
"coefficient": 2.2
}
]
}
]
}Точный алгоритм сопоставления дерева
js
matchLevel(nodes, attributes, absent):
matching = nodes where attributes[node.axis] == node.value
if matching is empty:
return absent
if matching contains more than one node:
reject malformed version; keep previous good version
return matchNode(matching[0], attributes, absent)
matchNode(node, attributes, absent):
matchingChild = node.children where
attributes[child.axis] == child.value
if matchingChild contains more than one node:
reject malformed version; keep previous good version
if exactly one child matched:
return matchNode(matchingChild, attributes, absent)
if node has coefficient:
return node.coefficient
return absentКритическая деталь: совпавший дочерний узел без coefficient сбрасывает коэффициент родителя. Нельзя просто запомнить последнее число по пути и вернуть его в конце — так исключённый подсегмент ошибочно унаследует показ.
Разбор примера
| Признаки показа | Результат | Почему |
|---|---|---|
desktop, safari | 4.82 | Совпал более специфичный потомок |
desktop, chrome | 1.35 | Потомок не совпал, применяется остаток desktop |
desktop, outdated_browser | 0 | Совпал явный исключающий потомок без коэффициента |
smartphone, moscow-city, 25-34 | 1.6 | Совпал полный путь до возраста |
smartphone, moscow-city, 35-44 | 0 | Узлы smartphone/city не имеют своего коэффициента |
tablet, city_cars | 2.2 | Совпала ветка leaf-интереса |
tablet без city_cars | 0 | Корень совпал, но его остаток не опубликован |
отсутствует device | 0 | Нельзя доказать совпадение с корнем |
На каждом показе возвращается одно число. 1.35 × 4.82, 1.35 + 4.82 и среднее этих чисел — ошибочные интерпретации.
Формат flat/v1
Плоский формат объёмнее, но каждая строка сразу содержит полное условие. segments гарантированно непересекаются: корректный набор признаков совпадает не более чем с одной строкой.
Поля строки
| Поле | Тип | Обязательно | Значение |
|---|---|---|---|
conditions | object string→string | да | Все условия сегмента; пустой объект не публикуется |
coefficient | number | да | Множитель этого сегмента |
Полный пример плоского файла
json
{
"schemaVersion": "demand-map/v1",
"format": "flat/v1",
"container": {
"generatedOnUtc": "2026-07-31T03:12:00Z",
"sourceFromUtc": "2026-05-02T00:00:00Z",
"sourceToUtc": "2026-07-31T00:00:00Z"
},
"coefficient": {
"meaning": "relativeDemand",
"baseline": 1.0,
"absent": 0,
"max": 10.0,
"kind": "multiplier"
},
"taxonomy": {
"conditionKeys": ["device", "browser", "city", "ageBand", "interest"]
},
"dictionaries": {
"countries": [
{ "code": "RU", "name": "Russia" }
],
"regions": [
{ "code": "moscow", "name": "Moscow", "countryCode": "RU" }
],
"cities": [
{
"code": "moscow-city",
"name": "Moscow",
"regionCode": "moscow",
"countryCode": "RU"
}
],
"interests": [
{
"code": "city_cars",
"name": "Town cars",
"sourceCode": "228"
}
]
},
"segments": [
{
"conditions": {
"device": "desktop",
"browser": "safari"
},
"coefficient": 4.82
},
{
"conditions": {
"device": "desktop",
"browser": "chrome"
},
"coefficient": 1.11
},
{
"conditions": {
"device": "smartphone",
"city": "moscow-city",
"ageBand": "25-34"
},
"coefficient": 1.6
},
{
"conditions": {
"device": "tablet",
"interest": "city_cars"
},
"coefficient": 2.2
}
]
}Алгоритм сопоставления плоского списка
js
matches(row, attributes):
return every (key, value) in row.conditions satisfies
attributes[key] == value
matchedRows = segments where matches(row, attributes)
if matchedRows is empty:
return coefficient.absent
if matchedRows contains more than one row:
reject malformed version; keep previous good version
return matchedRows[0].coefficientНеизвестный или отсутствующий признак делает конкретную строку несовпавшей. Его нельзя удалять из conditions перед проверкой.
Почему числа дерева и плоского списка могут различаться
Это не рассинхрон. В дереве коэффициент родителя относится к остатку: трафику узла, который не попал ни в один перечисленный дочерний узел. В плоском списке родительской строки «любое другое значение» нет. Остаток разворачивается в явные непересекающиеся комбинации, и каждая считается на собственных данных.
Поэтому:
text
tree: device=desktop (residual) → 1.35
flat: device=desktop AND browser=chrome → 1.11Это разные множества. Инвариант между форматами не в равенстве каждой цифры, а в том, что один показ попадает максимум в одно правило и масса не учитывается дважды.
Нераскрытые словарные значения
Sales Ninja не публикует внутренний географический ID или непонятный токен. Если значение country, region, city или interest нельзя раскрыть в стабильный словарный код, весь содержащий его узел/сегмент отбрасывается до публикации.
Нельзя оставить потомков и просто удалить проблемное условие: это расширило бы сегмент на чужую аудиторию. Потребитель применяет то же правило к своему парсеру: неизвестное условие делает правило несовпавшим, а не более широким.
Версионирование и совместимость
Известная версия и формат
Обрабатывайте только явно поддерживаемые сочетания:
ini
schemaVersion = demand-map/v1
format = tree/v1 OR flat/v1Неизвестный schemaVersion или format нельзя угадывать по похожим полям. Отклоните кандидат и продолжайте работать на предыдущей версии.
Аддитивные изменения
В пределах поддерживаемой версии потребитель должен:
- игнорировать незнакомое необязательное поле объекта;
- принимать новый элемент словаря;
- принимать новое корректное значение открытой оси;
- не путать новое поле с новым условием: неизвестный ключ внутри
conditionsили узла влияет на сопоставление и поэтому не может быть удалён.
Новое обязательное поведение или несовместимая смена семантики потребует новой schemaVersion.
Что проверять перед активацией
- верхний объект и все обязательные вложенные объекты существуют;
- формат содержит ровно соответствующую структуру
treeилиsegments; - все ключи условий входят в
taxonomy.conditionKeys; - все словарные коды находятся в соответствующем массиве;
countryCodeиregionCodeссылаются на существующие элементы, если они присутствуют в файле;- коэффициенты конечны, не отрицательны и не превышают объявленный
max; - плоские строки не пересекаются на контрольном наборе, а дерево не содержит неоднозначных совпадающих соседей;
- после построения индекса исходный JSON можно удалить, только если сохранены метаданные версии и словари, нужные для диагностики.
Приватность
Контейнер — агрегированный список правил. В нём нет:
- идентификаторов пользователей, сессий или событий;
- cookie, IDFA/GAID и других рекламных идентификаторов;
- IP-адресов и отпечатков;
- внутренних GUID;
- сырых URL посещений;
- персональных данных или списков аудитории.
Если ваш конвейер требует добавить такие данные к файлу, это уже другой контракт и не должно называться контейнером карты спроса.
Приёмочный чек-лист партнёра
- [ ] Поддерживается
demand-map/v1и нужныйformat. - [ ] Кандидат проверяется до атомарной замены рабочей версии.
- [ ] Последняя рабочая версия сохраняется при любой ошибке обновления.
- [ ]
generatedOnUtcзаписывается в диагностику. - [ ] Все оси из
conditionKeysдоступны во время показа. - [ ] Закрытые, открытые и словарные значения обрабатываются по разным правилам.
- [ ] Словари индексируются по
code, не по названию. - [ ] Неизвестное условие не удаляется из правила.
- [ ] Дерево возвращает самый специфичный результат и сбрасывает родителя на совпавшем узле без коэффициента.
- [ ] Плоский формат допускает максимум одно совпадение.
- [ ] Коэффициенты по пути не складываются и не перемножаются.
- [ ] Отсутствие совпадения возвращает
coefficient.absent. - [ ] Лимиты ставки/бюджета применяются после сопоставления.
- [ ] Логи не содержат постоянную ссылку и не обогащают файл персональными данными.
Частые ошибки
| Ошибка | Последствие | Правильно |
|---|---|---|
| Считать отсутствующий сегмент средним | Показы уйдут в неразмеченный трафик | Вернуть absent = 0 |
| Наследовать родителя через совпавший узел без коэффициента | Исключённая аудитория снова получит показ | Вернуть absent для остатка этого узла |
| Перемножать коэффициенты пути | Приоритет будет многократно завышен | Взять одно число самого специфичного результата |
| Игнорировать непонятное условие | Узкий сегмент расширится | Считать правило несовпавшим |
| Сопоставлять гео по названию | Омонимы и локализация дадут ошибки | Использовать code и связи кодов |
| Замораживать список браузеров | Новый браузер сломает весь файл | Принимать нормализованную открытую строку |
Обрезать segments после скачивания | Нарушится рассчитанное покрытие и остатки | Настроить реальный лимит до построения |
| Заменять файл до полной проверки | Частичная версия попадёт в serving | Candidate → validate → atomic swap |
Вопросы о смысле карты и настройке клиентом: обзор и руководство по созданию.