Если вам когда-либо приходилось вручную вставлять абзац в 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_remote_post() (Ресурсы для разработчиков WordPress)
- Руководство по REST API (WordPress)
- Справочник API OpenAI
- Расширение для работы с JSON (php.net)
- Нонсы (безопасность WordPress)
Ключ хранится в файле 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 и выше.
Ресурсы
- wp_remote_post() — Ресурсы для разработчиков WordPress
- Справочник по REST API — WordPress
- set_transient() — Ресурсы для разработчиков WordPress
- wp_kses_post() — Ресурсы для разработчиков WordPress
- Справочник API OpenAI
- Отладка WordPress — Ресурсы для разработчиков
- WordPress (зеркало) на GitHub — для поиска основного кода
- WordPress Core Trac — История и заявки
- json_decode() — PHP.net
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".
