Если вам когда-либо приходилось вручную вставлять абзац в Google Translate в 23 вечера, чтобы быстро опубликовать английскую версию, вы знаете, в чем проблема: это медленно, непоследовательно и в итоге превращается в копирование и вставку в редакторе. WordPress без какой-либо возможности отслеживания.

WordPress Версия 6.9.4 (апрель 2026 г.) уже предоставляет хорошие инструменты (REST API, временные переменные, хуки), но она ничего не «переводит» изначально. Идея проста: подключить API для перевода с помощью ИИ через wp_remote_post()без установки каких-либо плагинов, сохраняя при этом контроль над кэшированием, затратами и безопасностью.

Потребность / Вариант использования

Конкретная проблема: у вас есть контент (статьи(страницы, иногда поля ACF), и вы хотите быстро создать переведенную версию с приемлемым качеством, не добавляя громоздкий (и часто дорогостоящий) плагин перевода, который изменяет вашу базу данных и административную панель.

Я часто сталкивался с этой потребностью в следующих случаях:

  • Французские технические блоги, которым нужна англоязычная версия, "достаточная" для SEO-оптимизации по длинным запросам,
  • Демонстрационные веб-сайты (Avada/Divi/Elementor), на которых должно быть 10 страниц на 2 языках.
  • Сайты с очень стабильным контентом (документация, юридические страницы), где перевод практически никогда не меняется.

В итоге вы узнаете, как это реализовать:

  • Защищенная REST-точка доступа для запроса перевода на стороне администратора.
  • механизм перевода AI (например, OpenAI) через wp_remote_post(),
  • Один кэш на каждый пост + язык с временными данными.
  • «Внедрение» данных «на лету» (без дублирования записей) с помощью фильтра. the_content (необязательный),
  • надежная стратегия резервного копирования на случай медленной работы API или его недоступности. ошибка.

Краткое резюме

  • Вы храните ключ API в wp-config.php (никогда не в твердой форме).
  • Вы создаете мини-плагин (или mu-плагин), который предоставляет доступ к REST-интерфейсу только для администраторов.
  • Плагин вызывает API искусственного интеллекта с помощью wp_remote_post() + таймаут + обработка ошибок.
  • Перевод кэшируется через set_transient() (для каждого поста/языка + хеш контента).
  • Вариант 1: Отображение «на лету» (без дублирования). Вариант 2: Генерация и хранение в метаданных.
  • Вы добавляете ограничение скорости запросов + очистку HTML (wp_kses_post()) чтобы избежать неприятных сюрпризов.

Когда следует использовать ИИ для этого?

Используйте этот подход, когда:

  • Вы хотите получить «качественный» перевод, не создавая при этом полноценную многоязычную инфраструктуру?
  • Содержание преимущественно текстовое (статьи, страницы).
  • Вы принимаете несовершенный, но связный перевод, если он сопровождается глоссарием.
  • Вы хотите контролировать затраты (агрессивное кэширование, перевод по запросу),
  • Не стоит полагаться на плагин, который навязывает свою собственную модель данных.

По моему опыту, это очень хорошо работает для редакционных сайтов, где 80% страниц составляют статьи и где перевод используется в основном для привлечения аудитории (SEO + международная аудитория), а не для строгой локализации в соответствии с законодательством.

Когда НЕ следует использовать ИИ

Избегайте использования ИИ (или ограничьте его применение), когда:

  • У вас есть юридические требования (условия и положения, медицинские, финансовые): непроверенный перевод, выполненный с помощью ИИ, представляет собой риск.
  • У вас уже есть WPML/Polylang и эффективная многоязычная стратегия (URL-адреса для каждого языка, hreflang, меню и т. д.).
  • Вам необходимо перевести строки интерфейса (строки темы): лучше всего использовать специальный инструмент (gettext).
  • Необходимо перевести контент с высокой степенью динамичности (комментарии, пользовательский контент): затраты + GDPR + модерация.
  • У вас очень большой сайт (более 10 000 постов), и вы думаете, что можете "перевести всё сразу": счёт и квоты вас успокоят.

Более простой, «классический» вариант: если вам нужно отображать разный контент в зависимости от языка, ручное дублирование 10 страниц плюс меню для каждого языка иногда по-прежнему обеспечивает наилучшую окупаемость инвестиций. Искусственный интеллект в основном полезен, когда вы хотите автоматизировать процессы, не переделывая весь сайт.

Предпосылки

Версии

  • WordPress 6.9.4+ (апрель 2026 г.)
  • PHP 8.1+ (рекомендуется 8.2/8.3, если ваш хостинг-провайдер его поддерживает)
  • Необходимо включить протокол HTTPS (в противном случае вызов API будет нецелесообразен).

Ключ API (пример OpenAI)

Вы будете использовать API по протоколу HTTP. Изначально SDK и Composer не требуются. Официальная документация:

Ключ хранится в файле wp-config.php.

Добавить это в wp-config.phpВ идеале — с помощью переменной окружения, внедряемой хостинг-провайдером (ещё лучше), в противном случае — жёстко закодированной. wp-config.php (Допустимо, если файл хорошо защищен).

/** Clé API OpenAI - ne jamais commiter ce fichier dans un dépôt public */
define('BPCAB_OPENAI_API_KEY', 'sk-REMPLACEZ-MOI');

/** Modèle de traduction (à ajuster selon votre fournisseur) */
define('BPCAB_TRANSLATION_MODEL', 'gpt-4.1-mini');

Классическая ловушка: вставка этой константы в functions.phpЯ до сих пор вижу это на сайтах, использующих Divi/Avada: при первом переключении темы ключ «исчезает». Введите его. wp-config.php или в качестве переменной окружающей среды.

Архитектура решения

Схема потока (текстовая схема):

Администрирование WordPress → REST API (защищенная конечная точка) → wp_remote_post() → API ИИ (перевод) → валидация + очистка → кэш (временный + мета-опция) → возврат в формате JSON → отображение (опционально через фильтр)

Что происходит за кулисами?

  • запись : un post_idисходный язык, целевой язык и, возможно, «глоссарий».
  • Добыча : мы получаем контент (и заголовок, если хотите), затем подготавливаем его (с сохранением HTML).
  • Кэш Мы вычисляем ключ кэша на основе хеша контента и целевого языка. Если это не изменилось, мы не платим за API.
  • вызов API Запрос в формате JSON, разумный таймаут, минимальное количество повторных попыток (без бесконечного цикла).
  • Уборка Мы не доверяем возвращаемому HTML-коду. Мы используем wp_kses_post().
  • Sortie JSON-данные для административной панели, а также, при желании, отображение на стороне клиента в зависимости от языкового параметра.

Полный код — шаг за шагом

Мы собираемся создать мини-плагин. Я рекомендую... мю-плагин Если вы не хотите, чтобы клиент отключал его «в целях тестирования». В противном случае, используйте стандартный плагин.

Шаг 1 — Создайте mu-плагин

создать wp-content/mu-plugins/bpcab-ai-translate.phpЕсли файл mu-plugins не существует, создайте его.

Реальная ошибка: многие люди вставляют файл в wp-content/plugins Затем они забывают его активировать. Плагин mu загружается автоматически.

Шаг 2 — Объявите REST-конечную точку, доступную только для администраторов.

Мы предоставляем конечную точку, которая работает только для пользователя, обладающего соответствующими правами. edit_posts (при необходимости внесите корректировки) и для чего требуется REST nonce.

<?php
/**
 * Plugin Name: BPCAB AI Translate (sans plugin de traduction)
 * Description: Traduction IA à la demande via REST API + cache Transients.
 * Author: Votre Nom
 * Version: 1.0.0
 */

if (!defined('ABSPATH')) {
	exit;
}

add_action('rest_api_init', function () {
	register_rest_route('bpcab/v1', '/translate', [
		'methods'             => 'POST',
		'callback'            => 'bpcab_translate_endpoint',
		'permission_callback' => 'bpcab_translate_permission_check',
		'args'                => [
			'post_id' => [
				'type'              => 'integer',
				'required'          => true,
				'sanitize_callback' => 'absint',
			],
			'source' => [
				'type'              => 'string',
				'required'          => false,
				'default'           => 'fr',
				'sanitize_callback' => 'sanitize_key',
			],
			'target' => [
				'type'              => 'string',
				'required'          => true,
				'sanitize_callback' => 'sanitize_key',
			],
			'glossary' => [
				'type'              => 'string',
				'required'          => false,
				'default'           => '',
				'sanitize_callback' => 'sanitize_textarea_field',
			],
			'store_as_meta' => [
				'type'              => 'boolean',
				'required'          => false,
				'default'           => false,
			],
		],
	]);
});

function bpcab_translate_permission_check(WP_REST_Request $request) : bool {
	// Vérifie la capacité
	if (!current_user_can('edit_posts')) {
		return false;
	}

	// Vérifie le nonce REST (envoyé via X-WP-Nonce)
	$nonce = $request->get_header('x_wp_nonce');
	if (!$nonce || !wp_verify_nonce($nonce, 'wp_rest')) {
		return false;
	}

	return true;
}

Шаг 3 — Создание функции перевода (кэш + API)

Мы собираемся:

  • получить сообщение,
  • рассчитать стабильный ключ кэша,
  • При необходимости вызовите API.
  • Дезинфицирующее средство снова в продаже.
  • При желании можно сохранить в метаданных записи.
function bpcab_translate_endpoint(WP_REST_Request $request) : WP_REST_Response {
	$post_id       = (int) $request->get_param('post_id');
	$source        = (string) $request->get_param('source');
	$target        = (string) $request->get_param('target');
	$glossary      = (string) $request->get_param('glossary');
	$store_as_meta = (bool) $request->get_param('store_as_meta');

	$post = get_post($post_id);
	if (!$post || $post->post_status === 'trash') {
		return new WP_REST_Response([
			'error' => 'Post introuvable.',
		], 404);
	}

	// On limite aux types publics classiques (ajustez si vous traduisez des CPT)
	$allowed_types = ['post', 'page'];
	if (!in_array($post->post_type, $allowed_types, true)) {
		return new WP_REST_Response([
			'error' => 'Type de contenu non supporté pour la traduction.',
		], 400);
	}

	// Validation basique des langues (évite des clés de cache bizarres)
	if (!preg_match('/^[a-z]{2}(-[A-Z]{2})?$/', $source)) {
		$source = 'fr';
	}
	if (!preg_match('/^[a-z]{2}(-[A-Z]{2})?$/', $target)) {
		return new WP_REST_Response([
			'error' => 'Langue cible invalide (ex: en, en-US, es).',
		], 400);
	}

	$original_title   = (string) get_the_title($post);
	$original_content = (string) $post->post_content;

	// Si votre contenu contient des shortcodes lourds, c’est souvent mieux de traduire
	// le contenu "brut" et de laisser les shortcodes intacts.
	// Ici on envoie le HTML/shortcodes tels quels, et on demande explicitement de les préserver.
	$payload = [
		'title'   => $original_title,
		'content' => $original_content,
	];

	$translated = bpcab_translate_with_cache($post_id, $payload, $source, $target, $glossary);

	if (is_wp_error($translated)) {
		return new WP_REST_Response([
			'error'   => $translated->get_error_message(),
			'details' => $translated->get_error_data(),
		], 502);
	}

	if ($store_as_meta) {
		// Stockage simple : un meta par langue
		// Attention : si vous faites du SEO multilingue sérieux, vous voudrez un modèle plus propre.
		update_post_meta($post_id, '_bpcab_ai_title_' . strtolower($target), $translated['title']);
		update_post_meta($post_id, '_bpcab_ai_content_' . strtolower($target), $translated['content']);
	}

	return new WP_REST_Response([
		'post_id'          => $post_id,
		'source'           => $source,
		'target'           => $target,
		'translated_title' => $translated['title'],
		'translated_html'  => $translated['content'],
		'cached'           => (bool) $translated['cached'],
	], 200);
}

Шаг 4 — Временный кэш + вызов OpenAI через wp_remote_post()

Ключевой момент: При изменении содержимого необходимо изменить ключ кэша.Я использую хеш, состоящий из содержимого + заголовка + глоссария + шаблона. В противном случае вы будете использовать устаревший перевод в течение нескольких дней.

function bpcab_translate_with_cache(int $post_id, array $payload, string $source, string $target, string $glossary) {
	if (!defined('BPCAB_OPENAI_API_KEY') || !BPCAB_OPENAI_API_KEY) {
		return new WP_Error('bpcab_no_api_key', 'Clé API manquante. Définissez BPCAB_OPENAI_API_KEY dans wp-config.php.');
	}

	$model = defined('BPCAB_TRANSLATION_MODEL') ? (string) BPCAB_TRANSLATION_MODEL : 'gpt-4.1-mini';

	// Hash stable du contenu à traduire
	$hash_input = wp_json_encode([
		'model'    => $model,
		'source'   => $source,
		'target'   => $target,
		'glossary' => $glossary,
		'payload'  => $payload,
	]);

	if (!$hash_input) {
		return new WP_Error('bpcab_json_error', 'Impossible d’encoder le payload en JSON.');
	}

	$content_hash = hash('sha256', $hash_input);
	$transient_key = 'bpcab_tr_' . $post_id . '_' . strtolower($target) . '_' . substr($content_hash, 0, 16);

	$cached = get_transient($transient_key);
	if (is_array($cached) && isset($cached['title'], $cached['content'])) {
		$cached['cached'] = true;
		return $cached;
	}

	$result = bpcab_call_openai_translation($payload, $source, $target, $glossary, $model);

	if (is_wp_error($result)) {
		return $result;
	}

	// Cache 30 jours (à ajuster)
	set_transient($transient_key, [
		'title'   => $result['title'],
		'content' => $result['content'],
	], 30 * DAY_IN_SECONDS);

	return [
		'title'   => $result['title'],
		'content' => $result['content'],
		'cached'  => false,
	];
}

function bpcab_call_openai_translation(array $payload, string $source, string $target, string $glossary, string $model) {
	$endpoint = 'https://api.openai.com/v1/chat/completions';

	// Prompt conçu pour préserver HTML + shortcodes.
	// J’insiste sur "ne pas traduire les attributs, URLs, shortcodes".
	$system = "Vous êtes un moteur de traduction professionnel. Conservez strictement la structure HTML et les shortcodes WordPress (ex: 
		
		
, ). Ne traduisez pas les URLs, slugs, attributs HTML, classes CSS, ids, noms de fichiers. Ne modifiez pas les entités HTML. Retournez uniquement du JSON strict.";

	$glossary_block = '';
	if (!empty($glossary)) {
		$glossary_block = "Glossaire (à respecter strictement) :n" . $glossary;
	}

	$user = "Traduisez du {$source} vers {$target}.n"
		. $glossary_block . "nn"
		. "Retour attendu (JSON strict) :n"
		. "{n"
		. "  "title": "...",n"
		. "  "content": "..."n"
		. "}nn"
		. "Texte à traduire :n"
		. "TITLE:n" . $payload['title'] . "nn"
		. "CONTENT (HTML/shortcodes):n" . $payload['content'];

	$body = [
		'model' => $model,
		// Température basse pour limiter les variations
		'temperature' => 0.2,
		'messages' => [
			['role' => 'system', 'content' => $system],
			['role' => 'user', 'content' => $user],
		],
	];

	$args = [
		'headers' => [
			'Authorization' => 'Bearer ' . BPCAB_OPENAI_API_KEY,
			'Content-Type'  => 'application/json; charset=utf-8',
		],
		'body'        => wp_json_encode($body),
		'timeout'     => 25, // timeout réseau (secondes)
		'redirection' => 3,
	];

	$response = wp_remote_post($endpoint, $args);

	if (is_wp_error($response)) {
		return new WP_Error('bpcab_http_error', 'Erreur HTTP vers l’API : ' . $response->get_error_message(), [
			'wp_error' => $response,
		]);
	}

	$code = (int) wp_remote_retrieve_response_code($response);
	$raw  = (string) wp_remote_retrieve_body($response);

	if ($code < 200 || $code >= 300) {
		return new WP_Error('bpcab_api_status', 'Réponse API non OK (HTTP ' . $code . ').', [
			'status' => $code,
			'body'   => $raw,
		]);
	}

	$data = json_decode($raw, true);
	if (!is_array($data)) {
		return new WP_Error('bpcab_bad_json', 'JSON invalide retourné par l’API.', [
			'body' => $raw,
		]);
	}

	// Extraction "chat.completions"
	$content = $data['choices'][0]['message']['content'] ?? '';
	if (!is_string($content) || $content === '') {
		return new WP_Error('bpcab_empty_content', 'Réponse vide de l’API.', [
			'parsed' => $data,
		]);
	}

	// Le modèle est censé renvoyer du JSON strict, mais je ne lui fais jamais confiance.
	$translated = json_decode($content, true);
	if (!is_array($translated) || !isset($translated['title'], $translated['content'])) {
		return new WP_Error('bpcab_invalid_translation_format', 'Format de traduction invalide (JSON attendu).', [
			'model_output' => $content,
		]);
	}

	// Nettoyage : titre en texte, contenu en HTML autorisé WP
	$title_clean = sanitize_text_field((string) $translated['title']);
	$html_clean  = wp_kses_post((string) $translated['content']);

	return [
		'title'   => $title_clean,
		'content' => $html_clean,
	];
}

Шаг 5 — (Необязательно) Отображение перевода на экране во время работы

Если вы не хотите создавать страницы "/en/…", вы можете отображать перевод на лету с помощью соответствующей настройки. ?lang=enЭто удобно для тестирования, но это не полноценная многоязычная SEO-стратегия.

Я часто делаю это на этапе валидации: клиент кликает, сравнивает, мы корректируем глоссарий, и только потом решаем, сохранять ли его в метаданных или дублировать страницы.

add_filter('the_content', function ($content) {
	if (is_admin() || !is_singular()) {
		return $content;
	}

	// Langue demandée via query var simple
	$lang = isset($_GET['lang']) ? sanitize_key((string) $_GET['lang']) : '';
	if (!$lang || $lang === 'fr') {
		return $content;
	}

	global $post;
	if (!$post instanceof WP_Post) {
		return $content;
	}

	$source = 'fr';
	$target = $lang;

	$payload = [
		'title'   => (string) get_the_title($post),
		'content' => (string) $post->post_content,
	];

	// Glossaire vide ici, mais vous pouvez le remplir via une option.
	$translated = bpcab_translate_with_cache((int) $post->ID, $payload, $source, $target, '');

	if (is_wp_error($translated)) {
		// Fallback silencieux : on garde le contenu original
		return $content;
	}

	return $translated['content'];
}, 20);

Распространенная ошибка — установка слишком низкого приоритета для этого фильтра (например, 1), что приводит к нарушению работы шорткодов Elementor/Divi/Avada, которые выполняются позже. Приоритет 20 часто является хорошим компромиссом. Если ваш конструктор внедряет контент через собственные хуки, вам может потребоваться скорректировать этот параметр.

Полный собранный код

Скопируйте и вставьте этот файл как есть в wp-content/mu-plugins/bpcab-ai-translate.phpКлюч API остается в wp-config.php.

<?php
/**
 * Plugin Name: BPCAB AI Translate (sans plugin de traduction)
 * Description: Traduction IA à la demande via REST API + cache Transients (WP 6.9.4+, PHP 8.1+).
 * Version: 1.0.0
 */

if (!defined('ABSPATH')) {
	exit;
}

add_action('rest_api_init', function () {
	register_rest_route('bpcab/v1', '/translate', [
		'methods'             => 'POST',
		'callback'            => 'bpcab_translate_endpoint',
		'permission_callback' => 'bpcab_translate_permission_check',
		'args'                => [
			'post_id' => [
				'type'              => 'integer',
				'required'          => true,
				'sanitize_callback' => 'absint',
			],
			'source' => [
				'type'              => 'string',
				'required'          => false,
				'default'           => 'fr',
				'sanitize_callback' => 'sanitize_key',
			],
			'target' => [
				'type'              => 'string',
				'required'          => true,
				'sanitize_callback' => 'sanitize_key',
			],
			'glossary' => [
				'type'              => 'string',
				'required'          => false,
				'default'           => '',
				'sanitize_callback' => 'sanitize_textarea_field',
			],
			'store_as_meta' => [
				'type'              => 'boolean',
				'required'          => false,
				'default'           => false,
			],
		],
	]);
});

function bpcab_translate_permission_check(WP_REST_Request $request) : bool {
	if (!current_user_can('edit_posts')) {
		return false;
	}

	$nonce = $request->get_header('x_wp_nonce');
	if (!$nonce || !wp_verify_nonce($nonce, 'wp_rest')) {
		return false;
	}

	return true;
}

function bpcab_translate_endpoint(WP_REST_Request $request) : WP_REST_Response {
	$post_id       = (int) $request->get_param('post_id');
	$source        = (string) $request->get_param('source');
	$target        = (string) $request->get_param('target');
	$glossary      = (string) $request->get_param('glossary');
	$store_as_meta = (bool) $request->get_param('store_as_meta');

	$post = get_post($post_id);
	if (!$post || $post->post_status === 'trash') {
		return new WP_REST_Response(['error' => 'Post introuvable.'], 404);
	}

	$allowed_types = ['post', 'page'];
	if (!in_array($post->post_type, $allowed_types, true)) {
		return new WP_REST_Response(['error' => 'Type de contenu non supporté pour la traduction.'], 400);
	}

	if (!preg_match('/^[a-z]{2}(-[A-Z]{2})?$/', $source)) {
		$source = 'fr';
	}
	if (!preg_match('/^[a-z]{2}(-[A-Z]{2})?$/', $target)) {
		return new WP_REST_Response(['error' => 'Langue cible invalide (ex: en, en-US, es).'], 400);
	}

	$payload = [
		'title'   => (string) get_the_title($post),
		'content' => (string) $post->post_content,
	];

	$translated = bpcab_translate_with_cache($post_id, $payload, $source, $target, $glossary);

	if (is_wp_error($translated)) {
		return new WP_REST_Response([
			'error'   => $translated->get_error_message(),
			'details' => $translated->get_error_data(),
		], 502);
	}

	if ($store_as_meta) {
		update_post_meta($post_id, '_bpcab_ai_title_' . strtolower($target), $translated['title']);
		update_post_meta($post_id, '_bpcab_ai_content_' . strtolower($target), $translated['content']);
	}

	return new WP_REST_Response([
		'post_id'          => $post_id,
		'source'           => $source,
		'target'           => $target,
		'translated_title' => $translated['title'],
		'translated_html'  => $translated['content'],
		'cached'           => (bool) $translated['cached'],
	], 200);
}

function bpcab_translate_with_cache(int $post_id, array $payload, string $source, string $target, string $glossary) {
	if (!defined('BPCAB_OPENAI_API_KEY') || !BPCAB_OPENAI_API_KEY) {
		return new WP_Error('bpcab_no_api_key', 'Clé API manquante. Définissez BPCAB_OPENAI_API_KEY dans wp-config.php.');
	}

	$model = defined('BPCAB_TRANSLATION_MODEL') ? (string) BPCAB_TRANSLATION_MODEL : 'gpt-4.1-mini';

	$hash_input = wp_json_encode([
		'model'    => $model,
		'source'   => $source,
		'target'   => $target,
		'glossary' => $glossary,
		'payload'  => $payload,
	]);

	if (!$hash_input) {
		return new WP_Error('bpcab_json_error', 'Impossible d’encoder le payload en JSON.');
	}

	$content_hash  = hash('sha256', $hash_input);
	$transient_key = 'bpcab_tr_' . $post_id . '_' . strtolower($target) . '_' . substr($content_hash, 0, 16);

	$cached = get_transient($transient_key);
	if (is_array($cached) && isset($cached['title'], $cached['content'])) {
		$cached['cached'] = true;
		return $cached;
	}

	$result = bpcab_call_openai_translation($payload, $source, $target, $glossary, $model);

	if (is_wp_error($result)) {
		return $result;
	}

	set_transient($transient_key, [
		'title'   => $result['title'],
		'content' => $result['content'],
	], 30 * DAY_IN_SECONDS);

	return [
		'title'   => $result['title'],
		'content' => $result['content'],
		'cached'  => false,
	];
}

function bpcab_call_openai_translation(array $payload, string $source, string $target, string $glossary, string $model) {
	$endpoint = 'https://api.openai.com/v1/chat/completions';

	$system = "Vous êtes un moteur de traduction professionnel. Conservez strictement la structure HTML et les shortcodes WordPress (ex: 
		
		
, ). Ne traduisez pas les URLs, slugs, attributs HTML, classes CSS, ids, noms de fichiers. Ne modifiez pas les entités HTML. Retournez uniquement du JSON strict.";

	$glossary_block = '';
	if (!empty($glossary)) {
		$glossary_block = "Glossaire (à respecter strictement) :n" . $glossary;
	}

	$user = "Traduisez du {$source} vers {$target}.n"
		. $glossary_block . "nn"
		. "Retour attendu (JSON strict) :n"
		. "{n"
		. "  "title": "...",n"
		. "  "content": "..."n"
		. "}nn"
		. "Texte à traduire :n"
		. "TITLE:n" . $payload['title'] . "nn"
		. "CONTENT (HTML/shortcodes):n" . $payload['content'];

	$body = [
		'model' => $model,
		'temperature' => 0.2,
		'messages' => [
			['role' => 'system', 'content' => $system],
			['role' => 'user', 'content' => $user],
		],
	];

	$args = [
		'headers' => [
			'Authorization' => 'Bearer ' . BPCAB_OPENAI_API_KEY,
			'Content-Type'  => 'application/json; charset=utf-8',
		],
		'body'        => wp_json_encode($body),
		'timeout'     => 25,
		'redirection' => 3,
	];

	$response = wp_remote_post($endpoint, $args);

	if (is_wp_error($response)) {
		return new WP_Error('bpcab_http_error', 'Erreur HTTP vers l’API : ' . $response->get_error_message(), [
			'wp_error' => $response,
		]);
	}

	$code = (int) wp_remote_retrieve_response_code($response);
	$raw  = (string) wp_remote_retrieve_body($response);

	if ($code < 200 || $code >= 300) {
		return new WP_Error('bpcab_api_status', 'Réponse API non OK (HTTP ' . $code . ').', [
			'status' => $code,
			'body'   => $raw,
		]);
	}

	$data = json_decode($raw, true);
	if (!is_array($data)) {
		return new WP_Error('bpcab_bad_json', 'JSON invalide retourné par l’API.', [
			'body' => $raw,
		]);
	}

	$content = $data['choices'][0]['message']['content'] ?? '';
	if (!is_string($content) || $content === '') {
		return new WP_Error('bpcab_empty_content', 'Réponse vide de l’API.', [
			'parsed' => $data,
		]);
	}

	$translated = json_decode($content, true);
	if (!is_array($translated) || !isset($translated['title'], $translated['content'])) {
		return new WP_Error('bpcab_invalid_translation_format', 'Format de traduction invalide (JSON attendu).', [
			'model_output' => $content,
		]);
	}

	$title_clean = sanitize_text_field((string) $translated['title']);
	$html_clean  = wp_kses_post((string) $translated['content']);

	return [
		'title'   => $title_clean,
		'content' => $html_clean,
	];
}

// Optionnel : affichage à la volée via ?lang=en (pratique pour valider)
add_filter('the_content', function ($content) {
	if (is_admin() || !is_singular()) {
		return $content;
	}

	$lang = isset($_GET['lang']) ? sanitize_key((string) $_GET['lang']) : '';
	if (!$lang || $lang === 'fr') {
		return $content;
	}

	global $post;
	if (!$post instanceof WP_Post) {
		return $content;
	}

	$payload = [
		'title'   => (string) get_the_title($post),
		'content' => (string) $post->post_content,
	];

	$translated = bpcab_translate_with_cache((int) $post->ID, $payload, 'fr', $lang, '');

	if (is_wp_error($translated)) {
		return $content;
	}

	return $translated['content'];
}, 20);

Код Пояснение

Почему именно REST-интерфейс, а не кнопка в панели администратора?

Поскольку REST-интерфейс является стабильной «точкой входа», вы можете:

  • Вызовите функцию перевода из небольшого JS-скрипта в панели администратора.
  • Запуск пакетного перевода через WP-CLI (вариант ниже),
  • интегрируйте редакционный рабочий процесс.

И самое главное: REST заставляет вас правильно управлять правами доступа и одноразовыми числами (nonce).

Почему используется кэш временных данных, а не опция или файл?

Использование переходного процесса удобно по следующим причинам:

  • У него есть собственный срок годности.
  • Он совместим с объектным кэшем (Redis/Memcached), если он у вас есть.
  • это предотвращает загрязнение стола postmeta для экспресс-тестов.

При переходе к «серьезной» многоязычной производственной среде вы, скорее всего, будете хранить данные в метатегах (или создавать переведенные записи). В этом случае временные данные выступают в качестве буфера затрат.

Почему wp_kses_post() Что касается реакции ИИ?

Потому что у вас нет стопроцентного контроля над тем, что возвращает модель. Даже если вы попросите её использовать "строгий JSON", модель всё равно может работать некорректно, внедрять теги или "исправлять" ваш HTML-код, изменяя его.

wp_kses_post() Применяет белый список разрешенных тегов в контексте WordPress. Официальная документация: wp_kses_post().

Почему в ключе кэша содержится хеш полезной нагрузки?

Без хеширования вы будете кэшировать "сообщение 123 на английском языке" и предоставлять тот же перевод, даже если вы отредактируете сообщение. Хеширование делает кэш быстро реагирующим на изменения, не требуя сложной логики очистки.

Стоимость и оптимизация API

Стоимость зависит от поставщика, модели и, особенно, от объема текста. Я предлагаю вам реалистичный метод расчета, а не даю обещаний.

Практическая оценка

  • Обычный пост в блоге (800–1200 слов) после сериализации (HTML + шорткоды + подсказка) часто представляет собой несколько тысяч токенов.
  • Если вы переводите 100 статей в месяц без кэширования, то платите минимум за 100 запросов в месяц.

Я намеренно сохраняю общую формулировку: стоимость = количество токенов на входе × цена входа + количество токенов на выходе × цена выходаЦены часто меняются; проверяйте прайс-лист у вашего поставщика.

Оптимизации, оказывающие немедленное воздействие:

  • Длинный кэш (30 дней и более) и ключ на основе хеша содержимого.
  • «Мини» модель для перевода (часто этого достаточно).
  • Сократите подсказку : ваш system После стабилизации этот период может сократиться.
  • Переводите только окончательный вариант изображения. Избегайте отправки одного и того же блока 10 раз (конструктор шаблонов).

Ловушка высоких затрат, которую я часто вижу.

Люди тестируют в рабочей среде, обновляя страницу 30 раз. ?lang=enи удивляться, почему счет растет. Без кэша каждое обновление инициирует вызов. С использованием временного кэша и хеширования стабилизация происходит мгновенно.

Расширенные варианты и сценарии использования

Вариант 1 — Перевести и сохранить в метаданных, затем отобразить с помощью фильтра.

Если вы хотите избежать вызовов API на стороне клиента, храните данные в метатегах (store_as_meta=trueзатем отобразить метаданные, когда ?lang=xx есть презент.

add_filter('the_content', function ($content) {
	if (is_admin() || !is_singular()) {
		return $content;
	}

	$lang = isset($_GET['lang']) ? sanitize_key((string) $_GET['lang']) : '';
	if (!$lang || $lang === 'fr') {
		return $content;
	}

	global $post;
	if (!$post instanceof WP_Post) {
		return $content;
	}

	$stored = get_post_meta((int) $post->ID, '_bpcab_ai_content_' . strtolower($lang), true);
	if (is_string($stored) && $stored !== '') {
		return wp_kses_post($stored);
	}

	return $content;
}, 20);

Вариант 2 — Пакетная обработка через WP-CLI (идея, но не полный код)

Для перевода 200 записей одновременно WP-CLI часто оказывается более надежным инструментом, чем HTTP-запросы из административной панели. Вы можете создать команду, которая будет перебирать идентификаторы и вызывать bpcab_translate_with_cache() и хранит данные в метаданных.

Чтобы статья оставалась лаконичной, я не привожу здесь полный код WP-CLI, но в официальной документации все понятно: Сборник команд WP-CLI.

Вариант 3 — совместимость с Divi 5 / Elementor / Avada

  • Дива 5 Большая часть контента хранится в шорткодах/структурах. Предупреждение «не изменять шорткоды» имеет решающее значение. Тестируйте на сложной странице, иначе модули будут работать некорректно.
  • Elementor Часть контента находится в _elementor_data (JSON). Не используйте этот JSON в текущем виде для перевода. Вместо этого переводите «отображаемое» содержимое (или создайте специальный переводчик для схемы Elementor, это отдельный проект).
  • Авада (конструктор Fusion) Та же логика, что и в Divi: много шорткодов Fusion. Держите их строго при себе, иначе вы потеряете верстку.

Если ваш сайт в основном создан с помощью конструктора сайтов, самая безопасная стратегия: переводите только... текстовые поля (виджеты/модули), а не структура. Здесь вы имеете дело с конкретной разработкой для каждого разработчика.

Безопасность и передовой опыт

Никогда не раскрывайте ключ API на стороне клиента.

Никакого JavaScript, напрямую вызывающего OpenAI/Anthropic. Ваш ключ окажется в браузере. Все запросы должны проходить через ваш сервер WordPress.

Минимальное ограничение скорости

REST-интерфейс легко взломать (даже неопытному администратору). Добавьте простую блокировку для каждого пользователя.

function bpcab_rate_limit_or_fail(int $user_id, int $limit, int $window_seconds) {
	$key = 'bpcab_rl_' . $user_id;
	$data = get_transient($key);

	if (!is_array($data)) {
		$data = ['count' => 0, 'start' => time()];
	}

	$elapsed = time() - (int) $data['start'];
	if ($elapsed > $window_seconds) {
		$data = ['count' => 0, 'start' => time()];
	}

	$data['count']++;

	set_transient($key, $data, $window_seconds);

	if ($data['count'] > $limit) {
		return new WP_Error('bpcab_rate_limited', 'Rate limit atteint. Réessayez plus tard.', [
			'limit'  => $limit,
			'window' => $window_seconds,
		]);
	}

	return true;
}

Вы можете вызвать эту функцию в начале bpcab_translate_endpoint() с get_current_user_id()Это не идеальное решение, но оно позволяет избежать ситуации, когда "я кликаю 50 раз".

Проверка ввода

  • Строго соблюдайте санитарные нормы: absint, sanitize_key, sanitize_textarea_field.
  • Белый список типов записей.
  • Использование регулярных выражений для кодов языков во избежание использования экзотических ключей кэша.

GDPR / Данные, отправляемые в API

Если вы переведете:

  • данные пользователя (комментарии, формы),
  • конфиденциальные данные (электронная почта, адреса),

Вы отправляете эти данные третьей стороне. Проведите аудит: правовое основание, разрешение на обработку данных (DPA), срок хранения, анонимизация. Приведенный выше код ничего не анонимизирует.

Как тестировать и отлаживать

1) Включите ведение журнала.

В wp-config.php (в тестовой среде):

define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', false);

Официальный документ: Отладка в WordPress.

2) Проверьте REST-интерфейс с помощью curl.

Получите REST nonce из вашей административной сессии (например, через wpApiSettings.nonce (если у вас есть страница администрирования, которая предоставляет к ней доступ), или используйте REST-инструмент в панели администрирования. Пример curl (схема):

curl -X POST "https://votre-site.tld/wp-json/bpcab/v1/translate" 
  -H "Content-Type: application/json" 
  -H "X-WP-Nonce: VOTRE_NONCE" 
  -d '{"post_id":123,"source":"fr","target":"en","glossary":"WordPress=WordPressnExtension=plugin","store_as_meta":false}'

3) Проверьте кэш

Простой тест: выполните один и тот же запрос дважды. Во второй раз должен получиться следующий результат. "cached": trueЕсли это не так, то у вас есть:

  • Контент, который изменяется (конструктор, который внедряет метки времени),
  • другой глоссарий,
  • другая модель,
  • или кэш объектов, который активно очищается.

4) Проверьте выходные данные модели.

Если вы видите ошибку «Неверный формат перевода», войдите в систему. model_output (в тестовой среде) чтобы понять, что на самом деле возвращает модель.

Если это не сработает

Вот наиболее часто встречающиеся мне поломки, а также быстрый способ их устранения.

симптом Причина вероятна проверка Решение
HTTP 401 / «несанкционированный доступ» Недействительный или отсутствующий ключ API Контрольник BPCAB_OPENAI_API_KEY + необработанный ответ details.body Исправьте ключ, проверьте права на проект со стороны поставщика.
HTTP 429 Превышена квота / лимит тарифа поставщика посмотреть details.status и тело API Подождите, уменьшите громкость, включите кэш, используйте более лёгкую модель.
Истек Слишком мало времени ожидания или слишком медленный сервер Журналы PHP + временное увеличение timeout Увеличьте время выполнения до 40 секунд в пакетном режиме или выполните перевод вне интерфейса пользователя (WP-CLI).
Неработающий HTML (конструктор макетов) В модели изменены короткие коды/атрибуты. Сравните оригинал и перевод. Улучшить подсказку, переводить только текстовые области, сохранять по модулям.
Перевод никогда не бывает «скрытым». Нестабильный ключ кэша (разное содержимое при каждом вызове) Записать хеш в лог (на тестовом сервере) Перед хешированием очистите содержимое (удалите динамические блоки) или сохраните его в метаданных.
Ошибка 403 на конечной точке. Отсутствует/недействительный REST nonce или недостаточная емкость. Проверьте заголовок X-WP-Nonce + роль пользователя Правильно сгенерируйте одноразовый код (nonce), внесите необходимые корректировки. permission_callback

«Глупые», но частые ошибки

  • Код вставлен не в то место. : в фрагменте кода плагина, который минимизирует/редактирует PHP и нарушает кодировку. Предпочтительнее использовать файл mu-plugin.
  • Отсутствует точка с запятой У вас возникает ошибка 500. Посмотрите. wp-content/debug.log.
  • Неподходящий зацеп : если вы попытаетесь вызвать REST API до rest_api_initНичего не объявляется.
  • Производственные испытания Вы запускаете десятки платных звонков. Разбейте свои действия на этапы, а затем перенесите их.
  • PHP слишком устарел. : ввод текста + возврат : WP_REST_Response Они могут не работать на PHP 7.x. В данном случае мы ориентируемся на PHP 8.1 и выше.

Ресурсы

FAQ

Действительно ли оно "не требует плагинов"?

Без стороннего плагина перевода — да. Технически, вы всё равно добавляете код в виде mu-плагина (или пользовательского плагина). Это сделано намеренно: вы сохраняете контроль и избегаете сложной системы.

Создаёт ли это переведённые страницы с URL-адресами /en/?

Нет, с этим кодом это невозможно. Перевод отображается на лету. ?lang=en Или же вы можете хранить это в метатегах. Для создания полноценной структуры URL-адресов для каждого языка вам потребуется построить слой маршрутизации + hreflang + карты сайта (или использовать многоязычный плагин).

Почему бы не перевести напрямую? post_content А потом сохранить пост?

Потому что вы рискуете перезаписать исходный код. Всегда разделяйте исходный код и перевод (метатеги, тип записи «перевод» или дублирование). Я видел сайты, которые теряли свой оригинальный контент после неудачного цикла перевода.

Модель иногда возвращает текст, который не является JSON. Что мне делать?

Это распространённая ситуация. В этом случае код намеренно выдаёт ошибку. На реальном сайте можно добавить шаг "исправления" (повторный запрос), но это удваивает затраты. Я предпочитаю корректно завершать работу с ошибкой и проводить проверку. model_output на этапе подготовки.

Как мне перевести этот отрывок?

добавлять excerpt В полезной нагрузке запросите JSON-данные, содержащие excerptЗатем сохраните это как метаданные. Придерживайтесь того же принципа: очищайте текст, а не HTML.

Как поддерживать порядок в глоссарии?

Сохраните это в качестве параметра (например, get_option('bpcab_translation_glossary')) и передать его в функцию. Глоссарий из 20–50 строк значительно меняет согласованность, особенно в отношении терминов, относящихся к брендам.

Будет ли это работать с Elementor, если мои страницы полностью созданы в Elementor?

Зависит от ситуации. Если ваш "настоящий" контент находится в _elementor_data, переводить post_content Этого недостаточно. Для Elementor вам либо придётся переводить конечный результат (что рискованно), либо написать переводчик, который сканирует JSON-файл Elementor и переводит только текстовые поля.

Зачем использовать chat/completions А разве не существует выделенной точки доступа для «перевода»?

Поскольку подход «чата» позволяет ограничивать формат (JSON) и устанавливать строгие правила (сохраняя HTML/шорткоды), чистый конечный пункт «перевода» иногда проще, но предлагает меньше контроля над форматом вывода.

Как избежать перевода ненужных частей (кода, фрагментов)?

Добавьте к запросу правило: «Не переводите содержимое внутри тегов». <code> et <pre>Если у вас много кода, вы также можете предварительно обработать HTML и заменить эти блоки заполнителями перед отправкой, а затем повторно внедрить их после этого.

Временный кэш не сохраняется на моём хостинге. Почему?

Некоторые хостинг-провайдеры агрессивно очищают кэш, или ваш сайт может работать с объектным кэшем, имеющим собственные правила. В этом случае храните кэш в метатегах (более надежный способ) или добавьте правильно настроенный постоянный объектный кэш (Redis).

Можно ли заменить OpenAI на Mistral/Anthropic/Google?

Да: сохранить ту же архитектуру (кэш + очистка + ошибки), только заменить. bpcab_call_openai_translation() с помощью функции, которая вызывает их конечную точку с wp_remote_post()Не меняйте остальное, пока формат выходных данных остается "строгим JSON".