Перейти к содержимому

Playbook: фабрика страниц

Рецепт собирает любую страницу из готовых блоков: лендинг, статью, рубрику каталога, корзину, FAQ. Тип страницы определяется набором выбранных блоков — сам рецепт универсален.

Годится и человеку со curl, и ИИ-агенту. Агент, поставивший скиллы, делает всё это сам; здесь — что именно он делает.

Окно терминала
export API='https://page-craft.4partners.io/api/v1'
export KEY='pb_a1b2c3d4e5f6...'

Нужны curl и jq. Ключ создаётся по инструкции API-ключи.

Контракт POST /pages — прочитать до первого запроса

Заголовок раздела «Контракт POST /pages — прочитать до первого запроса»

Половина ошибок здесь — не в содержимом, а в именах полей.

Поле Тип Обяз. Заметка
name string да не title — именно name
site_id integer да число, не строка
kind string нет вид документа, по умолчанию article; список типов — GET /page-types. Неизменяем после создания
slug string нет уникален в пределах сайта; только у kind=article
folder_id integer нет ID папки внутри сайта
meta_title string нет плоское поле верхнего уровня, не meta: { title }
meta_description string нет так же плоское
meta_keywords string нет так же плоское
meta_robots string нет так же плоское
blocks array нет элементы — см. шаг 4

Минимальный валидный payload:

{ "site_id": 1, "name": "Заголовок", "slug": "page-slug", "blocks": [] }
Окно терминала
curl -s -H "Authorization: Bearer $KEY" "$API/sites?limit=50" | jq '.data[] | {id, name}'

Идентификатор нужного сайта — в $SITE.

Окно терминала
# поиском по названию и описанию
curl -s -H "Authorization: Bearer $KEY" "$API/blocks?search=hero&limit=20" \
| jq '.data[] | {id, name, description}'
# или по тегам
curl -s -H "Authorization: Bearer $KEY" "$API/tags?limit=50" | jq '.data[] | {id, name}'
curl -s -H "Authorization: Bearer $KEY" "$API/blocks?tags=1,3&limit=20" | jq '.data[] | {id, name}'

Полнотекстовый поиск с ранжированием — GET /search/blocks?q=… (запрос от двух символов).

Критичный шаг, пропускать нельзя. Контракт говорит, какие поля у блока есть и какого они типа.

Окно терминала
for BID in 3 7 12; do
echo "=== Block $BID ==="
curl -s -H "Authorization: Bearer $KEY" "$API/blocks/$BID/placeholder-schema" \
| jq '{block_type, schema, example, binding_slots}'
done
Поле ответа Что означает
block_type schema — обычный блок; markdown — WYSIWYG-блок
schema JSON Schema (Draft-07) для placeholder_values
example минимальный валидный пример из значений по умолчанию — можно отправить как есть
binding_slots слоты живых данных с полями name, allowed_sources, default, result_type; пустой массив у статических блоков

Структура зависит от block_type.

Поля заполняются строго по schema:

{
"block_id": 3,
"placeholder_values": {
"title": "Современный сайт для вашего бизнеса",
"subtitle": "Запустим лендинг за 3 дня",
"cta_label": "Оставить заявку",
"cta_url": "#contact"
}
}

Соответствие типов полей формату значения разобрано на странице Схема полей. Поля, которых нет в схеме, но которые использует Liquid-шаблон, тоже допустимы — валидация их не отклоняет.

Основной контент кладётся в зарезервированный ключ _markdown; поля схемы остаются опциональными и отвечают за оформление.

{
"block_id": 7,
"placeholder_values": {
"_markdown": "## О компании\n\nМы делаем сайты с 2018 года.\n\n- Лендинги за 72 часа\n- 50+ проектов"
}
}

Формат — GFM (GitHub Flavored Markdown): заголовки #, списки, ссылки, **bold**, ~~strike~~, картинки, цитаты, блоки кода и таблицы:

| Заголовок 1 | Заголовок 2 |
| ----------- | ----------- |
| Ячейка 1 | Ячейка 2 |

У таблицы обязательна строка-шапка и разделитель --- под ней; перенос строки внутри ячейки — <br>. Сервер конвертирует Markdown в HTML при публикации. Подробнее — Markdown в блоках.

Если binding_slots непустой, слоту назначается источник. PageCraft подставляет в предпросмотр заглушки (stub), поэтому «есть ли данные» проверять негде — важно правильно описать, что должно прилететь на витрине. Значения слотов по умолчанию — разумная заглушка, но не замена осмысленному выбору params.

{
"block_id": 12,
"placeholder_values": { "headline": "Свяжитесь с нами" },
"bindings": {
"products": {
"source": "catalog.products",
"params": { "limit": 8, "sort": "popular", "filter": { "rubric": "women-shoes" } }
}
}
}

Для каждого слота проходятся три запроса — это не опция.

1. Определение источника (для каждого значения из allowed_sources):

Окно терминала
curl -s -H "Authorization: Bearer $KEY" "$API/data-sources/catalog.products" | jq '.'

В ответе: id, title, description — назначение источника; result_type_id — идентификатор формы данных; params_schemaJSON Schema объекта params. Ключи properties — ровно то, что кладётся в params. Не угадывайте.

Все источники: GET /data-sources?limit=50.

2. Форма результата — что источник передаст в шаблон:

Окно терминала
curl -s -H "Authorization: Bearer $KEY" "$API/data-result-types/offer_list" | jq '{schema_json, stub}'

schema_json — JSON Schema результата, stub — готовый пример. В предпросмотре подставляется именно stub, на витрине — реальные данные той же формы. Список всех типов: GET /data-result-types?limit=50.

3. Вместимость блока против params.limit:

Окно терминала
curl -s -H "Authorization: Bearer $KEY" "$API/blocks/12" | jq -r '.current_revision.template_content'

В шаблоне ищется конструкция вывода ({% for item in products limit: 8 %}) или фиксированное число слотов в разметке. params.limit должен совпадать с ним: limit: 15 при limit: 8 в шаблоне — семь товаров потеряны; limit: 3 при восьми слотах — полблока пустое.

Три оси подбора params:

  1. Что братьfilter, slug, category_id. Конкретный ресурс под смысл секции, а не «всё подряд».
  2. Сколько братьlimit, синхронизированный с вместимостью блока.
  3. В каком порядкеsort по смыслу секции: «хиты» → popular, «новинки» → newest, «акции» → discount.

Разбор слотов и источников целиком — Биндинги и слоты.

Окно терминала
cat > /tmp/page-payload.json <<EOF
{
"name": "Страница нового продукта",
"site_id": $SITE,
"slug": "new-product-2026",
"meta_title": "Новый продукт — лучшее решение",
"meta_description": "Краткое описание для поисковиков.",
"blocks": [
{ "block_id": 3, "placeholder_values": { } },
{ "block_id": 7, "placeholder_values": { } },
{ "block_id": 12, "placeholder_values": { }, "bindings": { } }
]
}
EOF
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
--data-binary @/tmp/page-payload.json "$API/pages" | jq '.'

Порядок элементов blocks[] задаёт порядок блоков на странице. Успех — 201 с созданной страницей; её id пригодится дальше как $PID. Ответ разворачивает blocks[] с id созданных вхождений — они нужны, если дальше вы кладёте блоки в слоты (parent_page_block_id), и отдельный запрос за ними не требуется.

Не создалась — в ответе errors[]:

{
"error": "Ошибка валидации блоков",
"errors": [
{ "path": "blocks[0].placeholder_values.title", "message": "поле обязательно" },
{ "path": "blocks[2].bindings.products.source", "message": "источник не разрешён для слота" },
{ "path": "site_id", "message": "This value should be of type int." }
]
}

Как чинить:

  1. Найдите место в payload по path — dot-путь blocks[0].placeholder_values.title для блочной валидации, имя корневого поля (site_id, name, slug) для ошибок верхнего уровня.
  2. Исправьте по message. Типичное: «поле обязательно» — добавить поле; «источник не разрешён» — свериться с binding_slots[*].allowed_sources; should be of type int/string — привести тип; «should not be blank» — заполнить.
  3. Повторите запрос.

Отдельный случай — 409: slug уже занят другой страницей этого сайта. В errors[] придёт запись с path: "slug"; поменяйте slug и повторите.

404 с текстом вида blocks[i]: блок N не найден означает, что блок удалён или недоступен вашему ключу. Подберите замену через GET /blocks?search=… и повторите шаги 3–5.

Окно терминала
curl -s -H "Authorization: Bearer $KEY" "$API/pages/$PID?with_blocks=1" \
| jq '{id, name, blocks: [.blocks[] | {block_id: .block.id, name: .block.name, order_index, placeholder_values}]}'

Без with_blocks=1 поле blocks равно null — это не пустая страница, а «блоки не запрашивались». Правило про null касается только GET: ответы на создание и на изменяющие операции разворачивают блоки всегда.

Действие Эндпоинт
Заменить состав блоков целиком PUT /pages/{id} с blocks
Изменить только метаданные PUT /pages/{id} без blocks
Дописать блоки (опционально с position) POST /pages/{id}/blocks
Поправить значения одного блока PUT /pages/{pageId}/blocks/{pbId}
Переставить блок PUT /pages/{pageId}/blocks/{pbId}/move
Заменить блок другим PUT /pages/{pageId}/blocks/{pbId}/replace
Снять блок со страницы DELETE /pages/{pageId}/blocks/{pbId}
Подтянуть minor-версии всех блоков POST /pages/{pageId}/blocks/upgrade-all
Удалить страницу DELETE /pages/{id}204

Обновление до major-версии блока не выполняется автоматически: upgrade вернёт 422 с error: "major_upgrade_blocked", списком удалённых полей и текущей привязкой версии. Это не сбой, а требование перенести значения вручную — см. Устаревание и апгрейды.

Страница в PageCraft и статья на витрине — разные сущности. Пока страница не опубликована, её никто, кроме вас, не видит.

10a. Узнать статус. Запрос локальный, во внешний API не ходит:

Окно терминала
curl -s -H "Authorization: Bearer $KEY" "$API/pages/$PID/publication" | jq '.'
# → { "is_published": false, "external_id": null, "external_slug": null,
# "published_at": null, "updated_at": "..." }

10b. is_published: false — первая публикация:

Окно терминала
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{
"rubric_ids": [42],
"meta_title": "Новый продукт | Мой сайт",
"meta_description": "Краткое описание для поисковиков.",
"slug": "new-product",
"preview_text": "Анонс для списка статей."
}' "$API/pages/$PID/publish" | jq '.'

Обязательное поле одно — rubric_ids (минимум один идентификатор рубрики 4CMS). Остальные опциональны; name по умолчанию берётся из name страницы.

У документа с kindarticle (шаблон, макет) тело публикации пустое — {}: rubric_ids, slug и meta_* шаблону не передаются, их ведёт 4CMS. Подробности — Публикация и обновление.

Список рубрик это API не возвращаетrubric_ids берутся напрямую из API 4partners.io.

10c. is_published: true — обновление опубликованной статьи:

Окно терминала
# перегенерировать HTML/CSS/JS блоков, метаданные на витрине оставить как есть
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{}' "$API/pages/$PID/publish-update" | jq '.'
# или поменять часть метаданных
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"meta_title":"Новый заголовок","rubric_ids":[42,51]}' \
"$API/pages/$PID/publish-update" | jq '.'

Оба эндпоинта отвечают тем же объектом статуса, что и GET /pages/{id}/publication.

Ошибки публикации:

Код Причина
400 вызван publish на уже опубликованной странице — нужен publish-update
400 вызван publish-update на неопубликованной — нужен publish
400 у страницы нет сайта — сначала PUT /pages/{id} с site_id
404 страница не найдена или принадлежит другому пользователю
502 ошибка провайдера (4CMS): неверный токен, несуществующая рубрика и подобное
  • сайт выбран, site_id известен
  • блоки отобраны
  • для каждого вызван GET /blocks/{id}/placeholder-schema
  • placeholder_values заполнены по schema
  • bindings заданы только для блоков с непустым binding_slots
  • по каждому слоту пройдены data-sources и data-result-types
  • params.limit сверен с вместимостью шаблона
  • POST /pages отправлен с site_id
  • errors[] разобраны, запрос повторён
  • GET /pages/{id}?with_blocks=1 подтверждает состав
  • перед публикацией вызван GET /pages/{id}/publication
  • при первой публикации передан непустой rubric_ids[]
  • openapi/v1.json — пути, коды ответов, схемы CreatePagePublicRequest, PageBlockInput, BlockPlaceholderSchemaResponse, PublishPagePublicRequest, PagePublicationStatusResponse, MajorUpgradeBlockedError
  • backend/src/Controller/V1/Page/**, backend/src/Controller/V1/PageBlock/**
  • backend/src/Exception/BlockPayloadValidationException.php, backend/src/Exception/SlugConflictException.php
  • предшественник страницы: backend/public/docs/agents/playbooks/page-factory.md