Когда производитель с дилерской сетью из 85+ партнёров решает стандартизировать их сайты — первый инстинкт понятен: сделать JSON-фид с основного домена, настроить синхронизацию, и пусть у всех будет одинаковый актуальный каталог.

Проблема в том, что «одинаковый» и «хорошо индексируемый» — противоречащие друг другу требования, если реализовано наивно. Яндекс видит 85 сайтов с идентичным контентом и поступает предсказуемо.

Под катом — как мы решали эту задачу для крупнейшего производителя сельхозтехники в России (Ростов-на-Дону, 150+ моделей комбайнов и тракторов, 50 000 SKU запчастей): архитектура Lock/Edit на уровне инфоблоков Битрикс, Manticore Search для артикулов со спецсимволами, OpenAI Batch API для обогащения каталога и Python-автоматизация развёртывания 85 поддоменов.

Постановка задачи

Требования производителя:

  • Единый каталог техники у всех дилеров (актуальные ТТХ, официальные фото, единая терминология)

  • Централизованное обновление — изменил на основном домене, разошлось по всем

  • Brand Safety — дилер не может испортить продуктовый контент

Требования дилеров:

  • Региональное SEO — позиции по запросам «купить комбайн [город]»

  • Локальный коммерческий контент — цены, остатки, условия лизинга, местный сервис

  • Уникальность в глазах поисковика — не быть дублём основного домена

Техническое ограничение: объект КИИ → только российские платформы и сертифицированное ПО → 1С-Битрикс как CMS.

Проблема Full Overwrite

Стандарт, разработанный для дилерской сети, требовал полного дублирования каталога через JSON-фид. Фид обновляется раз в сутки.

Первоначальная реализация — Full Overwrite: при каждом обновлении фид перезаписывает все поля элемента инфоблока. В Битрикс событие OnBeforeIBlockElementUpdate не блокирует перезапись из внешнего источника по умолчанию.

Последствия аудита одного из дилеров (декабрь 2025):

  • Органический трафик по гео-запросам: −73% — Яндекс склеил поддомен с основным доменом как дубль

  • Конверсия в заявку: 0.05% — сайт работает как справочник

  • Любые SEO-правки дилера (Title, Description) аннулировались через 24 часа

Архитектура Lock/Edit

Решение — разделить поля инфоблока на два типа с разными правилами синхронизации.

Классификация полей

Lock-поля (перезаписываются фидом, управляет производитель):

  • NAME — название модели

  • ARTICLE — артикул

  • PREVIEW_TEXT / DETAIL_TEXT — базовое описание

  • PREVIEW_PICTURE / DETAIL_PICTURE — фотографии

  • PROPERTY_SPECS_* — технические характеристики

Edit-поля (защищены от перезаписи, управляет дилер):

  • PROPERTY_LOCAL_SEO_TITLE — региональный заголовок страницы

  • PROPERTY_LOCAL_DESCRIPTION_APPEND — региональное дополнение к описанию

  • PROPERTY_DEALER_COMMERCIAL_BLOCK — цены, условия, акции

  • PROPERTY_DEALER_SERVICES — услуги дилера (сервис, лизинг, трейд-ин)

Реализация через обработчик событий

php

<?php
// /local/php_interface/init.php

AddEventHandler(
    "iblock",
    "OnBeforeIBlockElementUpdate",
    ["FederatedSync", "ProtectEditFields"]
);

class FederatedSync
{
    // Список Edit-полей, защищённых от перезаписи фидом
    private static array $editFields = [
        'LOCAL_SEO_TITLE',
        'LOCAL_DESCRIPTION_APPEND',
        'DEALER_COMMERCIAL_BLOCK',
        'DEALER_SERVICES',
    ];

    public static function ProtectEditFields(array &$arFields): void
    {
        // Срабатываем только на обновления из фида
        if (empty($arFields['FROM_FEED'])) {
            return;
        }

        $elementId = (int)$arFields['ID'];
        if (!$elementId) {
            return;
        }

        // Получаем текущие значения Edit-полей до перезаписи
        $res = CIBlockElement::GetByID($elementId);
        if (!$current = $res->GetNextElement()) {
            return;
        }

        $currentProps = $current->GetProperties();

        // Восстанавливаем Edit-поля
        foreach (self::$editFields as $code) {
            if (isset($currentProps[$code])) {
                $arFields['PROPERTY_VALUES'][$code] =
                    $currentProps[$code]['VALUE'];
            }
        }
    }
}

Флаг FROM_FEED устанавливается в агенте синхронизации при вызове CIBlockElement::Update(). Это позволяет отличать обновления из фида от ручных правок в админке.

Структура поддоменов

redbrand-selmash.com          ← основной домен, каталог-эталон
├── krasnodar.redbrand-selmash.com   ← дилер Краснодар
├── omsk.redbrand-selmash.com        ← дилер Омск  
├── voronezh.redbrand-selmash.com    ← дилер Воронеж
└── ... (85 поддоменов)

Каждый поддомен — отдельный сайт в Битрикс Multisite, подключённый к тому же инфоблоку каталога, но с собственными Edit-полями и шаблоном.

Manticore Search: поиск артикулов со спецсимволами

Проблема

B2B-клиент знает точный артикул запчасти и ищет именно по нему. Один и тот же артикул может быть записан по-разному:

  • RSM 101-05-02

  • RSM-101.05.02

  • RSM101

  • РСМ 101/05/02

Дефолтный MySQL Full-Text Search в Битрикс не справляется с такими запросами — он воспринимает дефис, точку и слэш как разделители и ищет по частям.

Решение: Manticore Search

Manticore Search — это форк Sphinx с активной разработкой, совместимый с MySQL-протоколом. Интегрируется с Битрикс через компонент bitrix.search.

Конфиг (manticore.conf):

ini

index bitrix_catalog {
    source = bitrix_catalog_source
    path = /var/lib/manticore/data/catalog

    # Таблица символов: цифры и латиница с приведением к нижнему регистру
    charset_table = 0..9, A..Z->a..z, _,
                    U+410..U+42F->U+430..U+44F

    # КЛЮЧЕВАЯ НАСТРОЙКА: игнорируемые символы-разделители
    ignore_chars = -, ., /, \, _

    min_word_len = 1
    morphology = lemmatize_ru_all
    min_prefix_len = 3
    expand_keywords = 1
}

source bitrix_catalog_source : bitrix_catalog_base {
    sql_query = \
        SELECT be.ID, be.NAME, bep.VALUE as ARTICLE \
        FROM b_iblock_element be \
        LEFT JOIN b_iblock_element_property bep \
            ON bep.IBLOCK_ELEMENT_ID = be.ID \
            AND bep.IBLOCK_PROPERTY_ID = (
                SELECT ID FROM b_iblock_property 
                WHERE CODE = 'ARTICLE' LIMIT 1
            ) \
        WHERE be.IBLOCK_ID = 5 AND be.ACTIVE = 'Y'
}

Параметр ignore_chars — ключевой. Он заставляет Manticore игнорировать указанные символы как при индексации, так и при поиске. В итоге RSM-101.05.02 и RSM 101-05-02 индексируются и ищутся как RSM10105 02 — одинаково.

Результат: поиск находит товар по любому написанию артикула. Время ответа на запросы по каталогу 50k позиций — до 50ms.

OpenAI Batch API: AI-генерация описаний

Проблема

В 1С дилера 50 000 SKU запчастей. Описания — технические коды из конструкторской документации: «Вал 10.01.05», «Кронштейн 23.11.200-А». Фото есть у ~5% позиций. Каталог выглядит как Excel-выгрузка.

Парсинг каталога с сайта производителя запрещён политикой. Нанять копирайтеров на 50 000 описаний — ~400 часов работы.

Решение: Batch API

OpenAI Batch API позволяет отправить до 50 000 запросов одним пак��том. Обрабатывается в течение 24 часов. Стоимость — вдвое дешевле синхронного API.

Шаг 1: Выгрузка из 1С

python

import csv

# CSV из 1С: артикул, название
with open('parts_1c.csv', 'r', encoding='utf-8') as f:
    reader = csv.DictReader(f)
    parts = list(reader)

print(f"Позиций к обработке: {len(parts)}")
# Позиций к обработке: 49847

Шаг 2: Формирование JSONL

python

import json

def build_prompt(name: str, sku: str) -> str:
    return f"""Ты — инженер-технолог производителя сельхозтехники.
Запчасть: {name} (Артикул: {sku})

Напиши:
1. Описание детали (2-3 предложения) — что это, для чего нужна
2. Категорию узла (Гидравлика / Трансмиссия / Ходовая / Двигатель / Электрика / Кабина / Жатка / Другое)
3. ГОСТ или стандарт, если очевиден из названия

Ответ строго в JSON без markdown:
{{"description": "...", "category": "...", "gost": "..."}}"""


with open('batch_input.jsonl', 'w', encoding='utf-8') as f:
    for i, part in enumerate(parts):
        request = {
            "custom_id": f"part-{part['SKU']}",
            "method": "POST",
            "url": "/v1/chat/completions",
            "body": {
                "model": "gpt-4o-mini",
                "messages": [
                    {"role": "user", "content": build_prompt(
                        part['NAME'], part['SKU']
                    )}
                ],
                "max_tokens": 300,
                "response_format": {"type": "json_object"}
            }
        }
        f.write(json.dumps(request, ensure_ascii=False) + '\n')

Шаг 3: Отправка и ожидание

python

from openai import OpenAI

client = OpenAI()

# Загружаем файл
with open('batch_input.jsonl', 'rb') as f:
    batch_file = client.files.create(file=f, purpose='batch')

# Создаём батч
batch = client.batches.create(
    input_file_id=batch_file.id,
    endpoint='/v1/chat/completions',
    completion_window='24h'
)

print(f"Batch ID: {batch.id}")
# Batch ID: batch_abc123...
# Ждём 24 часа

Шаг 4: Импорт в Битрикс

python

import json
from bitrix24 import Bitrix24  # или через CIBlockElement напрямую

results = {}
with open('batch_output.jsonl', 'r', encoding='utf-8') as f:
    for line in f:
        item = json.loads(line)
        sku = item['custom_id'].replace('part-', '')
        try:
            content = json.loads(
                item['response']['body']['choices'][0]['message']['content']
            )
            results[sku] = content
        except (KeyError, json.JSONDecodeError):
            continue

# Импорт в свойство AI_DESCRIPTION инфоблока
for sku, data in results.items():
    # Находим элемент по артикулу и обновляем свойство
    CIBlockElement::SetPropertyValueCode(
        element_id,
        'AI_DESCRIPTION',
        data['description']
    )

Экономика:

  • 49 847 запросов × $0.000150 (gpt-4o-mini Batch input) = $7.48

  • Альтернатива: 400 часов × $15/час = $6 000

  • Множитель экономии: 800×

Автоматизация: 85 Schema.org за 15 минут

Проблема

Развернуть 85 поддоменов с уникальным локальным контентом вручную — 340 часов (85 × 4 часа на регион). При этом для каждого нужна разметка Schema.org с точными координатами склада.

Скрипт геокодирования и генерации Schema.org

python

import requests
import json
from pathlib import Path

YANDEX_API_KEY = "your_key_here"

def geocode_address(address: str) -> tuple[float, float] | tuple[None, None]:
    """Получаем координаты адреса через Yandex Geocoder API"""
    url = "https://geocode-maps.yandex.ru/1.x/"
    params = {
        "geocode": address,
        "apikey": YANDEX_API_KEY,
        "format": "json"
    }
    
    response = requests.get(url, params=params, timeout=5)
    data = response.json()
    
    try:
        members = data['response']['GeoObjectCollection']['featureMember']
        pos = members[0]['GeoObject']['Point']['pos']
        lon, lat = pos.split()
        return float(lat), float(lon)
    except (KeyError, IndexError, ValueError):
        return None, None


def generate_schema(dealer: dict, lat: float, lon: float) -> dict:
    """Генерируем Schema.org AutoDealer для дилера"""
    return {
        "@context": "https://schema.org",
        "@type": "AutoDealer",
        "name": dealer['name'],
        "url": f"https://{dealer['subdomain']}.основнойдомен.ru",
        "telephone": dealer['phone'],
        "email": dealer.get('email', ''),
        "address": {
            "@type": "PostalAddress",
            "streetAddress": dealer['street'],
            "addressLocality": dealer['city'],
            "addressRegion": dealer['region'],
            "postalCode": dealer['postal_code'],
            "addressCountry": "RU"
        },
        "geo": {
            "@type": "GeoCoordinates",
            "latitude": lat,
            "longitude": lon
        },
        "openingHoursSpecification": [
            {
                "@type": "OpeningHoursSpecification",
                "dayOfWeek": ["Monday", "Tuesday", "Wednesday",
                              "Thursday", "Friday"],
                "opens": "09:00",
                "closes": "18:00"
            }
        ]
    }


# Загружаем дилеров из CRM (CSV или API)
dealers = load_dealers_from_crm()  # ваша функция

output_dir = Path("schema_output")
output_dir.mkdir(exist_ok=True)

for dealer in dealers:
    lat, lon = geocode_address(
        f"{dealer['city']}, {dealer['street']}"
    )
    
    if lat is None:
        print(f"⚠️ Не удалось геокодировать: {dealer['name']}")
        continue
    
    schema = generate_schema(dealer, lat, lon)
    
    output_path = output_dir / f"{dealer['subdomain']}.json"
    with open(output_path, 'w', encoding='utf-8') as f:
        json.dump(schema, f, ensure_ascii=False, indent=2)

print(f"✅ Сгенерировано {len(list(output_dir.glob('*.json')))} файлов")
# ✅ Сгенерировано 85 файлов

Итоговые файлы импортируются в шаблоны Битрикс через REST API.

DSAC: сезонный контент по матрице

Техническая деталь, которую часто упускают при работе с агрорынком: фермер выбирает технику за 2–3 месяца до сезона. Контент, опубликованный в начале посевной, уже опоздал.

DSAC (Dynamic Seasonally-Adaptive Content) — это матрица запуска контента на поддоменах с опережением на 6–8 недель:

Месяц публикации

Регион

Триггер

Тип контента

Январь

Краснодарский край

Подготовка к сверхранней посевной

Статья + лендинг трактора

Март

Центральная Россия

Подготовка к посевной

Сравнение моделей + калькулятор

Май

Омская область

Старт посевной в Сибири

Акция на технику + условия лизинга

Август

Краснодарский край

Уборка пропашных

Настройки жатки для подсолнечника

SEO-эффект накопительный: поддомены, запущенные в январе 2026, должны выйти в топ по сезонным запросам к маю. Это и есть контрольная точка для оценки результата.

Что не сделали и почему

Server-Side Tracking — в бэклоге. Стандартная Яндекс.Метрика с Opt-In баннером теряет 70–80% данных из-за 152-ФЗ. SST решает проблему, но требует отдельного внедрения на каждом поддомене. Планируем на Q2 2026.

A/B-тестирование DSAC-копирайта — нужен минимальный трафик для статистической значимости. Запускаем после накопления базы.

Интеграция с Яндекс.Бизнес API — для автоматической синхронизации складских остатков и часов работы на картах. API нестабильный, ждём стабильной версии.

Итого: что построено за 120 часов

Команда: 4 человека (аналитик, разработчик, SEO, PM).

Компонент

Технология

Результат

Lock/Edit архитектура

Bitrix Event Handlers

Фид не ломает SEO дилеров

Мультисайт

Bitrix Multisite

85 поддоменов на одном инстансе

Поиск артикулов

Manticore Search

Поиск по любому написанию артикула

AI-описания

OpenAI Batch API (gpt-4o-mini)

50k описаний за $7.50

Schema.org

Python + Yandex Geocoder

85 файлов за 15 минут

Сезонный контент

DSAC-матрица

12-месячный план по регионам

Бизнес-результат оцениваем в июле 2026 — пик уборочной кампании. Метрики: лиды с поддоменов vs основной домен, позиции по гео-запросам, конверсия.


Теги: #bitrix #manticore #sphinx #seo #openai #b2b #агромаркетинг #cms

Олег Линьков, CEO & Tech Lead Webformula

С 2012 года разрабатываем цифровую инфраструктуру для крупнейших производителей сельхозтехники, спецтехники, средств защиты растений, селекционеров и производителей семян в России. Внедряем федеративные архитектуры данных и методологию DSAC.