Использование плагина Advanced Custom Fields (ACF) Flexible Content долгие годы было основным паттерном конструирования сложных страниц в WordPress. Однако в высоконагруженных enterprise-системах этот подход порождает критический архитектурный долг:
wp_postmeta в виде сотен разрозненных строк и тяжелых сериализованных PHP-массивов. Рендеринг одной страницы с 10 гибкими секциями генерирует десятки дополнительных SQL-запросов и перегружает буфер InnoDB.wp_posts.post_content в виде HTML-комментариев формата <!-- wp:namespace/block {...} -->...<!-- /wp:namespace/block -->.Перевод сотен тысяч исторических записей с ACF на нативные блоки Gutenberg вручную экономически нецелесообразен, а традиционные детерминированные PHP-скрипты миграции ломаются при малейших расхождениях в структуре устаревших данных (невалидный HTML из TinyMCE, разорванные связи медиафайлов). Решение задачи в 2026 году базируется на конвейерной архитектуре WP-CLI Batch Runner с интеграцией LLM-трансформатора, обеспечивающего обогащение данных, валидацию через AST-парсер parse_blocks() и сохранение ревизий для мгновенного отката (Rollback).
Архитектурный конвейер миграции состоит из четырех изолированных этапов с гарантией идемпотентности:
[ Таблица wp_postmeta ]
|
v (1. Экстракция и обогащение метаданных)
[ Обогащенный JSON: ACF Layout + Resolved Image URLs ]
|
v (2. LLM Транслятор с белым списком блоков)
[ Сериализованный HTML Gutenberg: <!-- wp:core/... --> ]
|
v (3. Серверная валидация: parse_blocks() & has_blocks())
+------+------------------------------------------+
| Успешно | Ошибка валидации
v v
[ wp_posts.post_content ] [ Мета-флаг: _migration_status = 'failed' ]
[ Запись ревизии WP ] [ Запись в лог ошибок WP-CLI ]
[ Мета-флаг: _migration_status = 'done' ]Запрос порции записей (например, по 50 штук) с фильтром по отсутствию метаполя _migration_status.
Преобразование числовых ID изображений в полные структуры с URL, alt-текстами и размерами.
Инференс модели с системным промптом, принуждающим использовать только блоки из белого списка Core.
Проверка структуры сгенерированного HTML функциями has_blocks() и parse_blocks().
Обновление post_content с созданием новой ревизии и простановкой статуса завершения.
wp_postmeta и ускорением TTFB с 1.2с до 180мс.@wordpress/block-editor и рендеринга на React Server Components.core/cover, core/columns, core/group) в соответствии с theme.json.font, оставшихся после визуального редактора TinyMCE.Ниже приведен эталонный производственный артефакт команды WP-CLI на PHP 8.4, выполняющей пакетную миграцию с экспоненциальной задержкой, обогащением вложений и гарантией отката.
<?php
declare(strict_types=1);
/**
* Author: Artur Yusupov (Fullstack Agentic Lab)
* Package: @agentic-lab/acf-migration
* Module: AcfToGutenbergMigrationCommand
* Standard: WordPress 6.8+ / PHP 8.4 / WP-CLI v2
*/
namespace AgenticLab\Migration;
use WP_CLI;
use WP_Post;
use Throwable;
final class AcfToGutenbergMigrationCommand
{
private string $apiKey;
private string $apiEndpoint = 'https://api.openai.com/v1/chat/completions';
public function __construct()
{
$this->apiKey = (string) (getenv('OPENAI_API_KEY') ?: '');
}
/**
* Пакетная конвертация ACF Flexible Content в блоки Gutenberg.
*
* ## OPTIONS
*
* [--post-type=<type>]
* : Тип записи для миграции. По умолчанию 'post'.
*
* [--batch=<number>]
* : Количество постов в одной пачке. По умолчанию 50.
*
* ## EXAMPLES
*
* wp agentic migrate-acf --post-type=post --batch=25
*
* @when after_wp_load
*/
public function __invoke(array $args, array $assocArgs): void
{
if ($this->apiKey === '') {
WP_CLI::error('Переменная окружения OPENAI_API_KEY не задана.');
}
$postType = (string) ($assocArgs['post-type'] ?? 'post');
$batchSize = (int) ($assocArgs['batch'] ?? 50);
WP_CLI::log("Запуск миграции для типа записей '{$postType}' с размером батча {$batchSize}...");
$totalMigrated = 0;
$totalErrors = 0;
while (true) {
$posts = get_posts([
'post_type' => $postType,
'posts_per_page' => $batchSize,
'post_status' => 'any',
'meta_query' => [
[
'key' => '_agentic_migration_status',
'compare' => 'NOT EXISTS',
],
],
]);
if (empty($posts)) {
WP_CLI::success("Все записи типа '{$postType}' успешно обработаны. Всего мигрировано: {$totalMigrated}. Ошибок: {$totalErrors}.");
break;
}
foreach ($posts as $post) {
if (!$post instanceof WP_Post) {
continue;
}
$status = $this->migrateSinglePost($post);
if ($status) {
$totalMigrated++;
} else {
$totalErrors++;
}
}
// Очистка памяти для предотвращения утечек в долгих процессах WP-CLI
$this->flushMemoryCache();
}
}
private function migrateSinglePost(WP_Post $post): bool
{
// 1. Извлечение гибкого контента ACF
$flexibleContent = get_field('page_flexible_content', $post->ID);
if (empty($flexibleContent) || !is_array($flexibleContent)) {
update_post_meta($post->ID, '_agentic_migration_status', 'skipped_no_acf');
WP_CLI::log("Пост #{$post->ID}: ACF-контент отсутствует. Пропущен.");
return true;
}
// 2. Обогащение контекста (Hydration): заменяем ID вложений на объекты с URL и Alt
$enrichedData = $this->hydrateAcfData($flexibleContent);
// 3. Генерация Gutenberg-разметки через LLM
try {
$gutenbergHtml = $this->transformWithLlm($enrichedData);
// 4. Серверная верификация блоков
if (!has_blocks($gutenbergHtml)) {
throw new \RuntimeException('LLM вернула ответ без зарегистрированных Gutenberg-комментариев.');
}
$parsed = parse_blocks($gutenbergHtml);
if (empty($parsed)) {
throw new \RuntimeException('Сгенерированный HTML не прошел парсинг ядра parse_blocks().');
}
// 5. Сохранение резервной копии старого контента в метаданные и обновление поста
update_post_meta($post->ID, '_legacy_acf_backup', $flexibleContent);
$updateResult = wp_update_post([
'ID' => $post->ID,
'post_content' => $gutenbergHtml,
], true);
if (is_wp_error($updateResult)) {
throw new \RuntimeException($updateResult->get_error_message());
}
update_post_meta($post->ID, '_agentic_migration_status', 'completed');
WP_CLI::success("Пост #{$post->ID} успешно мигрирован в блоки Gutenberg.");
return true;
} catch (Throwable $e) {
update_post_meta($post->ID, '_agentic_migration_status', 'failed');
update_post_meta($post->ID, '_agentic_migration_error', $e->getMessage());
WP_CLI::warning("Сбой миграции поста #{$post->ID}: " . $e->getMessage());
return false;
}
}
/**
* Обогащение данных ACF URL-адресами медиафайлов.
*/
private function hydrateAcfData(array $layouts): array
{
foreach ($layouts as &$layout) {
if (!is_array($layout)) {
continue;
}
foreach ($layout as $key => &$value) {
// Если поле содержит ID вложения изображения
if (str_contains($key, 'image') && is_numeric($value) && (int) $value > 0) {
$attachmentId = (int) $value;
$url = wp_get_attachment_image_url($attachmentId, 'full');
$alt = (string) get_post_meta($attachmentId, '_wp_attachment_image_alt', true);
$value = [
'id' => $attachmentId,
'url' => $url ?: '',
'alt' => $alt,
];
}
}
}
return $layouts;
}
/**
* Вызов LLM с алгоритмом Exponential Backoff для обработки Rate Limits (429).
*/
private function transformWithLlm(array $acfData): string
{
$jsonPayload = wp_json_encode($acfData, JSON_UNESCAPED_UNICODE);
$systemPrompt = <<<PROMPT
Вы — ведущий инженер ядра WordPress. Преобразуйте предоставленный JSON из ACF Flexible Content в чистый сериализованный HTML блоков Gutenberg.
Правила:
1. Используйте ТОЛЬКО нативные блоки ядра: core/paragraph, core/heading, core/image, core/quote, core/columns.
2. Формируйте валидные блочные комментарии: <!-- wp:heading {"level":2} --><h2>Заголовок</h2><!-- /wp:heading -->.
3. Верните ИСКЛЮЧИТЕЛЬНО HTML-разметку блоков без Markdown-обертки кода.
PROMPT;
$maxRetries = 3;
$attempt = 0;
while ($attempt < $maxRetries) {
$attempt++;
$response = wp_remote_post($this->apiEndpoint, [
'timeout' => 30,
'headers' => [
'Authorization' => 'Bearer ' . $this->apiKey,
'Content-Type' => 'application/json',
],
'body' => wp_json_encode([
'model' => 'gpt-4o-mini',
'messages' => [
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $jsonPayload],
],
'temperature' => 0.1,
]),
]);
$code = wp_remote_retrieve_response_code($response);
if ($code === 429) {
// Экспоненциальная задержка: 2, 4, 8 секунд
$sleepSeconds = (int) pow(2, $attempt);
WP_CLI::log("Rate limit (429). Ожидание {$sleepSeconds} секунд перед повтором...");
sleep($sleepSeconds);
continue;
}
if ($code !== 200 || is_wp_error($response)) {
throw new \RuntimeException("Сбой вызова AI API: HTTP {$code}");
}
$body = json_decode(wp_remote_retrieve_body($response), true);
$content = (string) ($body['choices'][0]['message']['content'] ?? '');
// Очистка от возможных случайных markdown-оберток
$fence = chr(96) . chr(96) . chr(96);
$cleanContent = trim(str_replace([$fence . 'html', $fence], '', trim($content)));
return $cleanContent;
}
throw new \RuntimeException('Превышено число попыток повторного вызова AI API.');
}
private function flushMemoryCache(): void
{
global $wpdb, $wp_object_cache;
$wpdb->queries = [];
if (is_object($wp_object_cache)) {
$wp_object_cache->group_ops = [];
$wp_object_cache->stats = [];
$wp_object_cache->memcache_debug = [];
$wp_object_cache->cache = [];
if (method_exists($wp_object_cache, '__remoteset')) {
$wp_object_cache->__remoteset();
}
}
wp_cache_flush();
}
}
// Регистрация команды в рантайме WP-CLI
if (defined('WP_CLI') && WP_CLI) {
WP_CLI::add_command('agentic migrate-acf', AcfToGutenbergMigrationCommand::class);
}Симптом: Аварийное завершение скрипта с ошибкой Fatal error: Allowed memory size of 536870912 bytes exhausted.
Причина: WordPress сохраняет все выполненные SQL-запросы в глобальном массиве $wpdb->queries при включенном режиме отладки или внутреннем кэшировании объектов.
Решение: Очищать кэш объектов (wp_cache_flush()) и сбрасывать массив $wpdb->queries после каждой пачки (батча) записей.
Симптом: В блоках core/image отсутствуют URL картинок и отображаются пустые заглушки.
Причина: Поля изображений в ACF часто хранят лишь целочисленный ID записи вложений, о котором внешняя LLM ничего не знает.
Решение: Обязательный этап препроцессинга (Hydration) на стороне PHP: преобразование ID в полный объект с url, alt и dimensions.
Симптом: Ошибки "Block is not registered" в консоли редактора и сломанная верстка фронтенда.
Причина: Модель генерирует воображаемые блоки вида <!-- wp:custom/accordion -->, которых нет в текущей теме.
Решение: Строго ограничить список разрешенных блоков системным промптом и валидировать результат через parse_blocks() со сверкой с белым списком ядра.
Симптом: Безвозвратная потеря оригинального контента при некорректной генерации структуры блоков.
Причина: Перезапись post_content без сохранения исходных метаданных ACF.
Решение: Сохранять дампы ACF в метаполе _legacy_acf_backup и использовать нативный механизм версий записей (Post Revisions).
Инспекция всех макетов Flexible Content и формирование белого списка целевых Core-блоков.
Выборка записей пачками по 50 штук с мета-запросом по отсутствию флага завершения.
Гидратация ссылок на медиафайлы, таксономии и связанных авторов через PHP.
Инференс модели с малой температурой (0.1), валидация через has_blocks() и parse_blocks().
Визуальная проверка случайной выборки 100 записей и удаление legacy метаданных после валидации.