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

Коротко вся статья одной картинкой: три системы принимают статью по HTTP, четвёртой приходится класть на сайт свой код.
Общий интерфейс
Начну с того, что получилось в итоге, — иначе непонятно, к чему все эти костыли.
Все четыре CMS спрятаны за одним абстрактным классом. Пайплайн генерации не знает, куда он публикует:
@dataclass class 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 — особенно про Битрикс, — расскажите в комментариях. Судя по тому, сколько времени я потратил на поиск ответов, материала на эту тему катастрофически мало.

