Четыре CMS, один интерфейс публикации: WordPress, 1С-Битрикс, InSales и Joomla

от автора

Полгода назад я делал сервис, который генерирует статьи и сам кладёт их на сайт клиента. Текстовая часть казалась сложной, а публикация — формальностью: ну дёрнем REST API, что там может быть.

Оказалось наоборот. Написать статью — это вызов модели и разбор ответа. А доставить её в CMS так, чтобы получилась настоящая страница с картинками, категорией и мета-тегами — это четыре разных мира, в каждом свои правила и свои способы соврать вам об успехе.

Ниже — что выяснилось про API четырёх систем: WordPress, 1С-Битрикс, InSales и Joomla. Больше всего места займёт Битрикс, потому что у него публикации через API нет вообще, и это отдельная история.

Четыре способа доставки статьи в CMS: REST у WordPress и Joomla, admin-API у InSales и собственный PHP-файл у 1С-Битрикс

Четыре способа доставки статьи в CMS: REST у WordPress и Joomla, admin-API у InSales и собственный PHP-файл у 1С-Битрикс

Коротко вся статья одной картинкой: три системы принимают статью по HTTP, четвёртой приходится класть на сайт свой код.

Общий интерфейс

Начну с того, что получилось в итоге, — иначе непонятно, к чему все эти костыли.

Все четыре CMS спрятаны за одним абстрактным классом. Пайплайн генерации не знает, куда он публикует:

@dataclassclass PublishResult:    post_id: str                 # везде строка: у WP и Битрикса int, у Joomla тоже, но пусть будет одно    post_url: Optional[str]    status: str                  # "publish" | "draft"class BaseCMSPublisher(ABC):    @abstractmethod    async def test_connection(self) -> dict: ...    @abstractmethod    async def publish(        self,        *,        title: str,        content_html: str,        slug: str,        status: str,        category_ids: list,        featured_media_id: Optional[int],    # WP: media ID, загруженный заранее        cover_image_bytes: Optional[bytes],  # Битрикс: сырые байты прямо в запросе        cover_filename: Optional[str],        meta_title: Optional[str],        meta_description: Optional[str],    ) -> PublishResult: ...    @abstractmethod    async def update_seo_meta(self, post_id: str, meta_title: str, meta_description: str) -> None: ...

Обратите внимание на два параметра для обложки: featured_media_id и cover_image_bytes. Это первое, обо что разбивается наивная абстракция. В WordPress картинка сначала загружается в медиабиблиотеку отдельным запросом, и в пост уходит её ID. В Битриксе никакой медиабиблиотеки с REST-доступом нет, и картинку приходится слать байтами в том же запросе, что и статью. Свести это к одному параметру не выйдет — модели разные на уровне устройства системы, и абстракция обязана это признать, а не прятать.

Второе, что не сводится: черновик. В WordPress это status: "draft". В Joomla — state: 0. В InSales черновиков как отдельной сущности нет вовсе, о чём ниже.

WordPress: как должно быть

WordPress здесь эталон, и весь publisher укладывается в семьдесят строк — по сути тонкая обёртка над HTTP-клиентом.

Аутентификация — Application Password, который пользователь заводит в своём профиле. Никаких OAuth-плясок, обычный Basic Auth поверх HTTPS. Создание поста — один POST на /wp-json/wp/v2/posts, в ответ приходит объект с id и link.

post = await self._wp.create_post(    title=title,    content=content_html,    slug=slug,    status=status,    categories=category_ids or [],    featured_media=featured_media_id,)return PublishResult(post_id=str(post["id"]), post_url=post.get("link"), status=status)

Единственное место, где приходится знать про экосистему, — SEO-мета. Ни Yoast, ни Rank Math, ни SEOPress не кладут title и description в стандартные поля поста: у каждого плагина свои мета-ключи, а какой стоит у пользователя — вы не знаете.

Выяснилось, что спрашивать и не нужно. WordPress молча игнорирует неизвестные ключи в meta, поэтому можно отправить сразу все варианты одним запросом:

await client.post(f"{self.api_base}/posts/{post_id}", json={"meta": {    "_yoast_wpseo_title":     title,   # Yoast SEO    "_yoast_wpseo_metadesc":  desc,    "rank_math_title":        title,   # Rank Math    "rank_math_description":  desc,    "_seopress_titles_title": title,   # SEOPress    "_seopress_titles_desc":  desc,}}, headers=self.headers)

Сработает то, что установлено, остальное осядет в базе безвредным мусором.

Держите этот раздел в голове как точку отсчёта. Дальше всё будет хуже.

InSales: черновик, которого нет

InSales — платформа для интернет-магазинов, и блог там — сущность второго сорта. API описан, работает предсказуемо, но одна деталь ломает логику.

У статьи нет статуса «черновик». Зато published_at в документации помечен как required — он обязателен всегда, и вот его описание дословно:

article[published_at] required — publication date, the article is invisible to users while publication date > current time

То есть механизм скрытия здесь один: дата публикации в будущем. Никакого отдельного флага черновика ждать не нужно — надо просто поставить дату, до которой вы точно не доживёте:

if is_draft:    # InSales требует published_at даже для черновика.    # Дата в далёком будущем гарантирует, что статья не всплывёт нигде.    article["published_at"] = "2099-12-31T23:59:59+00:00"    article["notice"] = ""else:    article["published_at"] = datetime.now(timezone.utc).isoformat()

Выглядит как хак, но это ровно то поведение, которое описано в документации InSales. Неочевидно тут другое: если по привычке поставить текущую дату и понадеяться на отдельный флаг, статья окажется видна покупателям — а вы будете уверены, что сохранили черновик.

Ещё мелочь, на которой можно посидеть полчаса: обложка грузится не в статью, а отдельным запросом на /admin/files.json — файл в base64 внутри JSON, а в ответе absolute_url, который уже подставляется в тело статьи.

Joomla: четыре способа получить ошибку на успешной операции

Joomla с четвёртой версии имеет полноценный REST API с токенами. Звучит отлично. На практике это самая капризная из четырёх систем, и почти все грабли — про то, что ошибка не означает «ничего не произошло».

Два разных пути к API

Первое, обо что спотыкаешься на чужих хостингах:

# Короткий /api/v1 работает только там, где в папке /api/ включено# переписывание URL. На части хостингов его нет, и живёт лишь /api/index.php/v1.self._base     = self._site_url + "/api/index.php/v1"   # работает всегдаself._base_alt = self._site_url + "/api/v1"             # красивее, но не везде

Документация показывает короткий вариант, и на своей машине он работает. На шаред-хостинге без нужного .htaccess — 404. Прямой путь через index.php работает везде, поэтому основным сделал его, а короткий оставил запасным вариантом с определением при первом запросе.

Alias, кириллица и коллизии

В Joomla alias (ЧПУ) должен быть уникален внутри категории. Если сервис публикует по тому же ключевому запросу второй раз, slug получается тот же, и прилетает:

Another Article in this category has the same alias

Лечится перебором с суффиксом:

base_alias = payload["alias"]for attempt in range(1, 7):    payload["alias"] = base_alias if attempt == 1 else f"{base_alias}-{attempt}"    r = await client.post(f"{self._base}/content/articles", headers=self._headers, json=payload)    if r.status_code < 400 or "same alias" not in r.text:        break

С категориями отдельная засада: кириллицу в alias отдавать нельзя вообще. Joomla по умолчанию выбрасывает не-ASCII символы, и от русского названия остаётся мусор или пустая строка. А дальше вторая созданная категория получает такой же пустой alias — и падает с той же ошибкой про дубликат. Транслитерировать нужно на своей стороне, до отправки.

Ошибка приходит после того, как статья создана

Вот это стоило мне пары часов и нескольких дублей на тестовом сайте.

Плагины Joomla — чаще всего «Умный поиск» (Smart Search) — выполняются после того, как статья записана в базу. Если плагин падает, API возвращает 400. Статья при этом уже есть на сайте.

Наивная обработка ошибки означает вот что: пользователь видит «публикация не удалась», нажимает «повторить», и на сайте появляется второй экземпляр. И третий.

Поэтому на любую ошибку сначала идём смотреть, не создалось ли:

if r.status_code >= 400:    # Плагины падают уже ПОСЛЕ записи статьи в базу: Joomla отдаёт 400,    # а статья на сайте есть. Если её не подобрать — следующая попытка создаст дубль.    saved = await self._find_article(client, payload["alias"], title)    if saved:        return PublishResult(post_id=str(saved), post_url=..., status=status)

Тонкость: искать надо аккуратно. Статья с таким же названием могла лежать здесь и раньше — тогда мы выдадим чужую публикацию за свою и потом будем её перезаписывать. Засчитывать стоит только то, что появилось прямо сейчас.

Та же логика с картинками. Повторная публикация упирается в уже загруженный файл:

File exists and overwriting not requested

Формально ошибка. Фактически картинка на месте — берём её и идём дальше, а не теряем обложку.

И маленькое, но показательное. Изначально в коде стояла двойка как ID категории по умолчанию — это «Uncategorised» свежей установки Joomla. На сайтах, где эту категорию удалили, система заводила новую категорию с названием «2». Хардкод дефолтов из своей тестовой установки — плохая идея; правильно спросить у сайта, что у него реально есть.

1С-Битрикс: API для записи не существует

Теперь главное блюдо.

Если загуглить «Битрикс API создать элемент инфоблока», вы найдёте iblock.element.add и обрадуетесь. Радоваться рано: штатного метода с таким именем нет. Обычно его путают с lists.element.add из Битрикс24 — это другой продукт с другим API.

Официальная документация REST для инфоблоков говорит прямо:

В настоящий момент работает Read-only режим доступа к элементам инфоблока. Доступны следующие методы получения и фильтрации записей: iblock.Element.get, iblock.Element.list

Оговорка там же: можно сделать свою реализацию контроллера — целиком свой или наследника штатного с переопределением методов. Способ рабочий, но заметьте, что он значит на практике: чтобы принимать статьи извне, нужно положить свой PHP-код на сайт клиента. То есть ровно то, от чего мы пытались уйти, выбирая REST.

Так что выбор не между «через API» и «через файл», а между двумя способами положить код на чужой сайт.

Вариантов остаётся три: модуль из Маркетплейса (долго, дорого, вы зависите от чужого кода), свой REST-контроллер (нужно лезть в /local/, настраивать urlrewrite и включать REST для инфоблока) — или один PHP-файл, который пользователь кладёт в корень сайта. Я выбрал третье как самое короткое для пользователя: файл, секретный ключ внутри, приём JSON по POST.

define('BRIDGE_API_KEY', 'CHANGE_ME');// Грузим ядро Битриксаdefine('NO_KEEP_STATISTIC', true);define('NO_AGENT_CHECK', true);define('NOT_CHECK_PERMISSIONS', true);define('DisableEventsCheck', true);$docRoot = realpath(__DIR__);$_SERVER['DOCUMENT_ROOT'] = $docRoot;require_once($docRoot . '/bitrix/modules/main/include/prolog_before.php');

Дальше начинается то, ради чего я вообще сел писать эту статью.

Битрикс очень хочет что-нибудь напечатать

Первая проблема: пролог Битрикса печатает свой вывод. Если просто подключить его и потом отдать JSON, на выходе получится HTML-мусор, а перед ним — ваш заголовок Content-Type: application/json.

Лечится буферизацией. Но одного ob_start() мало:

require_once($prologFile);// Сбрасываем вывод пролога, но буферизацию НЕ выключаем: Битрикс ставит// собственный обработчик исключений, который печатает HTML-страницу ошибки.// Без буфера она уедет вызывающему вместо нашего JSON, и бэкенд получит// неразбираемый 500.ob_end_clean();ob_start();header('Content-Type: application/json; charset=utf-8');

Плюс к этому — свой обработчик фатальных ошибок, иначе любая ошибка в чужом обработчике события превращает ответ в HTML-дамп:

register_shutdown_function(function () {    $err = error_get_last();    if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {        while (ob_get_level()) ob_end_clean();        http_response_code(500);        header('Content-Type: application/json; charset=utf-8');        echo json_encode(['error' => 'PHP fatal: ' . $err['message']]);    }});

Элемент создан, но ID вам не вернули

Самая красивая проблема из всех.

CIBlockElement::Add() сначала пишет строку в базу, а потом вызывает событие OnAfterIBlockElementAdd. Если на сайте висит сторонний обработчик этого события — обычно виноват модуль seo или карта сайта — и он падает, то элемент в базе уже есть, а вам возвращается ошибка без ID.

С точки зрения вызывающей стороны публикация провалилась. С точки зрения сайта — статья опубликована. Повторный вызов создаст дубль.

Поэтому на ошибку мы идём искать только что созданный элемент:

/** * Найти элемент, который был создан, но чей ID до нас не доехал. */function bridge_find_element($iblockId, $code, $name = ''){    if ($code !== '') {        $res = \CIBlockElement::GetList(            ['ID' => 'DESC'],            ['IBLOCK_ID' => (int)$iblockId, '=CODE' => $code],            false, ['nTopCount' => 1], ['ID']        );        if ($row = $res->Fetch()) return (int)$row['ID'];    }    // Автотранслитерация могла переписать CODE — ищем по точному названию    if ($name !== '') {        $res = \CIBlockElement::GetList(            ['ID' => 'DESC'],            ['IBLOCK_ID' => (int)$iblockId, '=NAME' => $name],            false, ['nTopCount' => 1], ['ID']        );        if ($row = $res->Fetch()) return (int)$row['ID'];    }    return false;}

Найденный элемент превращает «публикация потеряна» в «публикация прошла». Вызывающей стороне при этом стоит сообщить отдельным флагом: статья на сайте, но у вас сломан обработчик события — чинить владельцу сайта, а не нам.

CODE, который переписывают за вашей спиной

Заметили в предыдущем куске поиск по названию? Он там не для красоты.

На части установок Битрикса висит обработчик OnBeforeIBlockElementAdd, который перезаписывает CODE транслитерацией из NAME. Вы передали аккуратный slug how-to-choose-oil-viscosity, а в базе оказалось kak-vybrat-vyazkost-masla. Ссылки, которые вы вернули пользователю, ведут в никуда.

Обойти обработчик события изнутри штатного API нельзя. Пришлось дописывать CODE прямым запросом уже после создания:

// Форсим CODE напрямую, в обход обработчиков автотранслитерации$con = \Bitrix\Main\Application::getConnection();$safeCode = $con->getSqlHelper()->forSql($baseSlug);$con->query("UPDATE b_iblock_element SET CODE='" . $safeCode . "' WHERE ID=" . (int)$elementId);

Да, это лезть в таблицу мимо API. Другого способа я не нашёл: событие отработает в любом случае, а бороться с ним «правильно» означало бы просить пользователя лезть в код своего сайта.

SEO-мета: один класс пишет, другой читает

Мета-теги элемента инфоблока живут в наследуемых свойствах (InheritedProperty). В D7 для них два класса с обманчиво похожими именами:

  • ElementTemplatesзапись, у него есть set()

  • ElementValuesчтение, у него getValues() и clearValues()

Перепутать их легко, потому что в старых версиях set() был и у второго. Итог — три уровня fallback: сначала штатный ElementTemplates::set(), потом ElementValues для старых сборок, а если и это недоступно — прямая запись в таблицу шаблонов, у которой, к слову, разная схема в разных версиях: в новых связь через IBLOCK_ID, в старых — через ENTITY_TYPE и ENTITY_ID.

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

И ссылка на статью

Мелочь напоследок: URL готовой статьи нельзя собрать самому. Он строится по шаблону инфоблока, который у каждого сайта свой. Правильный способ — попросить сам Битрикс:

$elRes = \CIBlockElement::GetList([], ['=ID' => $elementId, '=IBLOCK_ID' => $iblockId], ...);$el = $elRes->GetNext();   // именно GetNext(): он считает DETAIL_PAGE_URL по шаблону

GetNext(), в отличие от Fetch(), подставляет значения в шаблон URL. Если в шаблоне используется #ELEMENT_ID# вместо #ELEMENT_CODE#, придётся дополнительно заменить числовой ID на slug.

Что из этого следует

Если свести четыре истории к нескольким мыслям.

Код ошибки не отвечает на вопрос «создалось ли». Это оказалось главным. И Joomla с падающим плагином, и Битрикс с обработчиком события ведут себя одинаково: запись в базе есть, ответ — ошибка. Любая интеграция, которая пишет данные в чужую систему, обязана уметь проверить постфактум, что там реально произошло. Иначе вы получаете дубли на каждой второй попытке, причём у пользователя, а не у себя.

Идемпотентность важнее обработки ошибок. Дешевле спроектировать повторный вызов так, чтобы он не создавал второй экземпляр, чем пытаться перечислить все способы, которыми чужая CMS может соврать.

Абстракция обязана признавать различия, а не прятать их. Попытка свести медиа к одному параметру, а статусы к одному enum ломается на первой же системе, где модель другая. Лучше честные два поля с комментарием, почему их два.

Хардкод дефолтов из своей тестовой установки — источник самых странных багов. Категория с названием «2» на чужом сайте появилась именно так.

Документация описывает счастливый путь, а не ваш. Read-only режим у инфоблоков Битрикса и обязательный published_at у InSales честно написаны в официальной документации — их просто никто не читает до того, как споткнётся. А вот про то, что короткий путь к API Joomla требует rewrite, и про плагины, которые роняют ответ уже после записи в базу, не написано нигде: это выясняется на живых сайтах пользователей.

Если у вас есть свои истории про интеграции с российскими CMS — особенно про Битрикс, — расскажите в комментариях. Судя по тому, сколько времени я потратил на поиск ответов, материала на эту тему катастрофически мало.

ссылка на оригинал статьи https://habr.com/ru/articles/1080270/