Si vous avez déjà collé un paragraphe dans Google Translate à la main à 23h pour publier “vite fait” une version anglaise, vous savez où ça coince : c’est lent, incohérent, et ça finit en copier-coller dans l’éditeur WordPress sans aucune traçabilité.

WordPress 6.9.4 (avril 2026) vous donne déjà de bons outils (REST API, transients, hooks), mais il ne “traduit” rien nativement. L’idée ici est simple : brancher une API IA de traduction via wp_remote_post(), sans installer de plugin, et garder le contrôle sur le cache, les coûts et la sécurité.

Le besoin / Le cas d’usage

Le problème concret : vous avez du contenu (articles, pages, parfois des champs ACF) et vous voulez générer une version traduite, rapidement, avec une qualité acceptable, sans ajouter un plugin de traduction lourd (et souvent coûteux) qui modifie votre base de données et votre back-office.

J’ai souvent vu ce besoin sur :

  • des blogs techniques FR qui veulent une version EN “suffisante” pour le SEO long tail,
  • des sites vitrine (Avada/Divi/Elementor) où 10 pages doivent exister en 2 langues,
  • des sites à contenu très stable (docs, pages légales), où la traduction ne bouge presque jamais.

À la fin, vous saurez implémenter :

  • un endpoint REST sécurisé pour demander une traduction côté admin,
  • un moteur de traduction IA (exemple OpenAI) via wp_remote_post(),
  • un cache par post + langue avec les Transients,
  • un rendu “à la volée” (sans dupliquer les posts) via un filtre sur the_content (optionnel),
  • une stratégie de fallback propre si l’API est lente ou en erreur.

Résumé rapide

  • Vous stockez la clé API dans wp-config.php (jamais en dur).
  • Vous créez un mini-plugin (ou mu-plugin) qui expose un endpoint REST admin-only.
  • Le plugin appelle l’API IA avec wp_remote_post() + timeout + gestion d’erreurs.
  • La traduction est mise en cache via set_transient() (par post/langue + hash du contenu).
  • Option 1 : affichage “on-the-fly” (pas de duplication). Option 2 : génération et stockage en meta.
  • Vous ajoutez rate limiting + nettoyage HTML (wp_kses_post()) pour éviter les mauvaises surprises.

Quand utiliser l’IA pour ça

Utilisez cette approche quand :

  • vous voulez une traduction “bonne” sans bâtir une infrastructure multilingue complète,
  • le contenu est majoritairement textuel (articles, pages),
  • vous acceptez une traduction non parfaite mais cohérente si vous fournissez un glossaire,
  • vous voulez contrôler le coût (cache agressif, traduction à la demande),
  • vous ne voulez pas dépendre d’un plugin qui impose son propre modèle de données.

Dans mon expérience, ça marche très bien pour des sites éditoriaux où 80% des pages sont des articles et où la traduction sert surtout à l’acquisition (SEO + lecteurs internationaux), pas à une localisation juridique stricte.

Quand ne PAS utiliser l’IA

Évitez l’IA (ou limitez-la) quand :

  • vous avez des exigences légales (CGV, médical, finance) : une traduction IA non relue est un risque,
  • vous avez déjà WPML/Polylang en place et une vraie stratégie multilingue (URL par langue, hreflang, menus, etc.),
  • vous devez traduire des chaînes d’interface (theme strings) : mieux vaut un outil dédié (gettext),
  • vous devez traduire du contenu très dynamique (commentaires, UGC) : coût + RGPD + modération,
  • vous avez un site très gros (10k+ posts) et vous pensez “tout traduire d’un coup” : la facture et les quotas vont vous calmer.

Alternative “classique” souvent plus simple : si votre besoin est juste d’afficher un contenu différent selon la langue, une duplication manuelle de 10 pages + un menu par langue reste parfois le meilleur ROI. L’IA sert surtout quand vous voulez automatiser sans transformer tout votre site.

Prérequis

Versions

  • WordPress 6.9.4+ (avril 2026)
  • PHP 8.1+ (8.2/8.3 recommandé si votre hébergeur suit)
  • HTTPS activé (sinon l’appel API est une mauvaise idée)

Clé API (exemple OpenAI)

Vous allez utiliser l’API via HTTP. Pas de SDK, pas de Composer dans un premier temps. Les docs officielles :

Stockage de la clé dans wp-config.php

Ajoutez ceci dans wp-config.php, idéalement via une variable d’environnement injectée par l’hébergeur (encore mieux), sinon en dur dans wp-config.php (acceptable si le fichier est bien protégé).

/** 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');

Piège classique : coller cette constante dans functions.php. Je le vois encore sur des sites Divi/Avada : au premier switch de thème, la clé “disparaît”. Mettez-la dans wp-config.php ou en variable d’environnement.

Architecture de la solution

Flux (schéma textuel) :

Admin WordPress → REST API (endpoint sécurisé) → wp_remote_post() → API IA (traduction) → validation + nettoyage → cache (Transient + option meta) → retour JSON → affichage (optionnel via filtre)

Ce qui se passe en coulisses

  • Entrée : un post_id, une langue source, une langue cible, et éventuellement un “glossaire”.
  • Extraction : on récupère le contenu (et le titre si vous voulez), puis on le prépare (HTML conservé).
  • Cache : on calcule une clé de cache basée sur le hash du contenu + langue cible. Si ça n’a pas changé, on ne paye pas l’API.
  • Appel API : requête JSON, timeout raisonnable, retries minimalistes (pas de boucle infinie).
  • Nettoyage : on ne fait pas confiance au HTML retourné. On passe par wp_kses_post().
  • Sortie : JSON pour l’admin, et optionnellement un rendu front selon un paramètre de langue.

Le code complet — étape par étape

On va créer un mini-plugin. Je recommande un mu-plugin si vous ne voulez pas qu’un client le désactive “pour tester”. Sinon, plugin classique.

Étape 1 — Créer un mu-plugin

Créez wp-content/mu-plugins/bpcab-ai-translate.php. Si le dossier mu-plugins n’existe pas, créez-le.

Erreur réaliste : beaucoup de gens collent le fichier dans wp-content/plugins puis oublient de l’activer. Un mu-plugin se charge automatiquement.

Étape 2 — Déclarer un endpoint REST admin-only

On expose un endpoint qui ne marche que pour un utilisateur ayant la capacité edit_posts (ajustez si besoin) et qui exige un nonce REST.

<?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;
}

Étape 3 — Construire la fonction de traduction (cache + API)

On va :

  • récupérer le post,
  • calculer une clé de cache stable,
  • appeler l’API si nécessaire,
  • sanitizer le retour,
  • optionnellement stocker en post meta.
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);
}

Étape 4 — Cache Transients + appel OpenAI via wp_remote_post()

Le point clé : la clé de cache doit changer si le contenu change. J’utilise un hash du contenu + titre + glossaire + modèle. Sinon, vous allez servir une traduction obsolète pendant des jours.

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,
	];
}

Étape 5 — (Optionnel) Afficher la traduction à la volée sur le front

Si vous ne voulez pas créer de pages “/en/…”, vous pouvez afficher une traduction à la volée via un paramètre ?lang=en. C’est pratique pour tester, mais ce n’est pas une stratégie SEO multilingue complète.

Je le fais souvent en phase de validation : le client clique, compare, on ajuste le glossaire, puis seulement après on décide de stocker en meta ou de dupliquer des pages.

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);

Piège classique : mettre ce filtre avec une priorité trop basse (ex: 1) et casser des shortcodes d’Elementor/Divi/Avada qui s’exécutent plus tard. La priorité 20 est souvent un bon compromis. Si votre builder injecte le contenu via ses propres hooks, vous devrez peut-être ajuster.

Le code assemblé complet

Copiez-collez ce fichier tel quel dans wp-content/mu-plugins/bpcab-ai-translate.php. La clé API reste dans 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);

Explication du code

Pourquoi un endpoint REST plutôt qu’un bouton dans l’admin ?

Parce que l’endpoint REST est un “point d’entrée” stable. Vous pourrez ensuite :

  • appeler la traduction depuis un petit script JS dans l’admin,
  • lancer des traductions en batch via WP-CLI (variante plus bas),
  • brancher un workflow editorial.

Et surtout : REST vous force à gérer proprement permissions + nonce.

Pourquoi le cache Transients et pas une option ou un fichier ?

Le transient est pratique car :

  • il a une expiration native,
  • il est compatible avec object cache (Redis/Memcached) si vous en avez,
  • il évite de polluer la table postmeta pour des tests rapides.

Quand vous passez en production multilingue “sérieuse”, vous stockerez probablement en meta (ou vous créerez des posts traduits). Ici, le transient est votre amortisseur de coûts.

Pourquoi wp_kses_post() sur la réponse IA ?

Parce que vous ne contrôlez pas à 100% ce que renvoie le modèle. Même si vous lui demandez “JSON strict”, un modèle peut déraper, injecter des balises, ou “réparer” votre HTML en le modifiant.

wp_kses_post() applique la whitelist de balises autorisées dans le contexte WordPress. Doc officielle : wp_kses_post().

Pourquoi un hash du payload dans la clé de cache ?

Sans hash, vous allez mettre en cache “post 123 en EN” et servir la même traduction même si vous modifiez le post. Le hash rend le cache sensible aux changements, sans avoir à maintenir une logique de purge compliquée.

Coûts API et optimisation

Le coût dépend du fournisseur, du modèle, et surtout du volume de texte. Je vous donne une méthode de calcul réaliste, pas une promesse.

Estimation pratique

  • Un article de blog “moyen” (800–1200 mots) représente souvent quelques milliers de tokens une fois sérialisé (HTML + shortcodes + prompt).
  • Si vous traduisez 100 articles/mois, sans cache, vous payez 100 appels/mois minimum.

Je garde volontairement la formule générique : coût = tokens_entrée × prix_entrée + tokens_sortie × prix_sortie. Les prix bougent souvent ; vérifiez votre page de pricing fournisseur.

Optimisations qui ont un impact immédiat :

  • Cache long (30 jours ou plus) et clé basée sur hash du contenu.
  • Modèle “mini” pour de la traduction (souvent suffisant).
  • Réduire le prompt : votre system peut être plus court une fois stabilisé.
  • Traduire uniquement le rendu final : évitez d’envoyer 10 fois le même bloc (templates builder).

Piège coût que je vois souvent

Les gens testent sur production, rafraîchissent 30 fois une page avec ?lang=en, et se demandent pourquoi la facture grimpe. Sans cache, chaque refresh déclenche un appel. Avec le transient + hash, vous stabilisez immédiatement.

Variantes et cas d’usage avancés

Variante 1 — Traduire et stocker en meta, puis afficher via filtre

Si vous voulez éviter les appels API sur le front, stockez en meta (store_as_meta=true) puis affichez la meta quand ?lang=xx est présent.

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);

Variante 2 — Batch via WP-CLI (idée, pas code complet)

Pour traduire 200 posts d’un coup, WP-CLI est souvent plus fiable que des requêtes HTTP depuis l’admin. Vous pouvez créer une commande qui boucle sur des IDs, appelle bpcab_translate_with_cache() et stocke en meta.

Je ne mets pas le code complet WP-CLI ici pour garder l’article focalisé, mais la doc officielle est claire : WP-CLI Commands Cookbook.

Variante 3 — Compatibilité Divi 5 / Elementor / Avada

  • Divi 5 : beaucoup de contenu est stocké en shortcodes/structures. Le prompt “ne pas modifier les shortcodes” est crucial. Testez sur une page complexe, sinon vous aurez des modules cassés.
  • Elementor : une partie du contenu est dans _elementor_data (JSON). Ne traduisez pas ce JSON avec ce code tel quel. Traduisez plutôt le contenu “rendu” (ou créez un traducteur dédié au schéma Elementor, ce qui est un autre chantier).
  • Avada (Fusion Builder) : même logique que Divi, beaucoup de shortcodes Fusion. Conservez-les strictement, sinon vous perdez la mise en page.

Si votre site est majoritairement builder, la stratégie la plus sûre est : traduire uniquement les zones de texte (widgets/modules) et pas la structure. Là, vous êtes sur un développement spécifique par builder.

Sécurité et bonnes pratiques

Ne jamais exposer la clé API côté client

Pas de JavaScript qui appelle directement OpenAI/Anthropic. Votre clé finirait dans le navigateur. Toute requête doit passer par votre serveur WordPress.

Rate limiting minimal

Un endpoint REST peut être martelé (même par un admin maladroit). Ajoutez un verrou simple par utilisateur.

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;
}

Vous pouvez appeler cette fonction au début de bpcab_translate_endpoint() avec get_current_user_id(). Ce n’est pas parfait, mais ça évite le “je clique 50 fois”.

Validation des entrées

  • Sanitize strict : absint, sanitize_key, sanitize_textarea_field.
  • Whitelist de post types.
  • Regex sur les codes langue pour éviter des clés de cache exotiques.

RGPD / données envoyées à l’API

Si vous traduisez :

  • des données utilisateur (commentaires, formulaires),
  • des données sensibles (emails, adresses),

vous envoyez ces données à un tiers. Faites un audit : base légale, DPA, durée de conservation, anonymisation. Le code ci-dessus n’anonymise rien.

Comment tester et déboguer

1) Activez les logs

Dans wp-config.php (sur un environnement de staging) :

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

Doc officielle : Debugging in WordPress.

2) Testez l’endpoint REST avec curl

Récupérez un nonce REST depuis votre session admin (par exemple via wpApiSettings.nonce si vous avez une page admin qui l’expose), ou utilisez un outil REST dans l’admin. Exemple curl (schématique) :

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) Vérifiez le cache

Test simple : lancez deux fois la même requête. La seconde doit renvoyer "cached": true. Si ce n’est pas le cas, vous avez :

  • un contenu qui change (builder qui injecte des timestamps),
  • un glossaire différent,
  • un modèle différent,
  • ou un object cache qui purge agressivement.

4) Vérifiez la sortie modèle

Si vous voyez l’erreur “Format de traduction invalide”, logguez model_output (dans un environnement de test) pour comprendre ce que le modèle renvoie réellement.

Si ça ne marche pas

Voici les pannes que je rencontre le plus souvent, avec une méthode de vérification rapide.

Symptôme Cause probable Vérification Solution
HTTP 401 / “unauthorized” Clé API invalide ou absente Vérifier BPCAB_OPENAI_API_KEY + réponse brute dans details.body Corriger la clé, vérifier les droits du projet côté fournisseur
HTTP 429 Quota dépassé / rate limit fournisseur Regarder details.status et le body API Attendre, réduire le volume, activer cache, utiliser un modèle plus léger
Timeout Timeout trop bas ou serveur lent Logs PHP + augmenter temporairement timeout Monter à 40s en batch, ou traduire hors front (WP-CLI)
HTML cassé (mise en page builder) Le modèle a modifié des shortcodes/attributs Comparer original vs traduit Renforcer le prompt, traduire seulement des zones texte, stocker par module
Traduction jamais “cached” Clé de cache instable (contenu différent à chaque appel) Logguez le hash (sur staging) Nettoyer le contenu avant hash (retirer blocs dynamiques), ou stocker en meta
Erreur 403 sur l’endpoint Nonce REST manquant/invalide ou capacité insuffisante Vérifier header X-WP-Nonce + user role Générer le nonce correctement, ajuster permission_callback

Erreurs “bêtes” mais fréquentes

  • Code collé au mauvais endroit : dans un snippet plugin qui minifie/édite le PHP et casse l’encodage. Préférez un fichier mu-plugin.
  • Point-virgule manquant : vous obtenez une erreur 500. Regardez wp-content/debug.log.
  • Hook inadapté : si vous essayez d’appeler la REST API avant rest_api_init, rien ne se déclare.
  • Test sur production : vous déclenchez des dizaines d’appels payants. Faites un staging, puis migrez.
  • PHP trop ancien : typage + retour : WP_REST_Response peuvent casser sur PHP 7.x. Ici on cible PHP 8.1+.

Ressources

FAQ

Est-ce vraiment “sans plugin” ?

Sans plugin de traduction tiers, oui. Techniquement vous ajoutez quand même du code sous forme de mu-plugin (ou plugin custom). C’est volontaire : vous gardez la main et vous évitez une usine à gaz.

Est-ce que ça crée des pages traduites avec des URLs /en/ ?

Non, pas avec ce code. Vous affichez une traduction à la volée via ?lang=en ou vous stockez en meta. Pour une vraie structure d’URLs par langue, il faut construire une couche de routage + hreflang + sitemaps (ou utiliser un plugin multilingue).

Pourquoi ne pas traduire directement post_content et enregistrer le post ?

Parce que vous risquez d’écraser votre source. Séparez toujours source et traduction (meta, CPT “translation”, ou duplication). J’ai vu des sites perdre leur contenu original après une mauvaise boucle de traduction.

Le modèle renvoie parfois du texte hors JSON. Que faire ?

C’est fréquent. Le code échoue volontairement dans ce cas. Sur un site réel, vous pouvez ajouter une étape de “réparation” (re-prompt) mais ça double les coûts. Je préfère échouer proprement et inspecter model_output sur staging.

Comment traduire aussi l’extrait (excerpt) ?

Ajoutez excerpt au payload, demandez un JSON avec excerpt, puis stockez-le en meta. Gardez le même principe : sanitize texte, pas HTML.

Comment gérer un glossaire propre ?

Stockez-le dans une option (ex: get_option('bpcab_translation_glossary')) et passez-le à la fonction. Un glossaire de 20–50 lignes change beaucoup la cohérence, surtout pour des termes de marque.

Ça marche avec Elementor si mes pages sont 100% Elementor ?

Ça dépend. Si votre contenu “réel” est dans _elementor_data, traduire post_content ne suffit pas. Pour Elementor, il faut soit traduire le rendu final (risqué), soit écrire un traducteur qui parcourt le JSON Elementor et ne traduit que les champs texte.

Pourquoi utiliser chat/completions et pas un endpoint “translation” dédié ?

Parce que l’approche “chat” permet de contraindre le format (JSON) et de donner des règles strictes (préserver HTML/shortcodes). Un endpoint “translation” pur est parfois plus simple, mais moins contrôlable sur le format de sortie.

Comment éviter de traduire des morceaux non souhaités (code, snippets) ?

Ajoutez une règle dans le prompt : “Ne traduisez pas le contenu dans les balises <code> et <pre>”. Si vous avez beaucoup de code, vous pouvez aussi pré-traiter le HTML et remplacer ces blocs par des placeholders avant envoi, puis les réinjecter après.

Le cache transient ne persiste pas sur mon hébergement. Pourquoi ?

Certains hébergeurs purgent agressivement, ou votre site tourne avec un object cache qui a ses propres règles. Dans ce cas, stockez en meta (plus durable) ou ajoutez un object cache persistant (Redis) correctement configuré.

Puis-je remplacer OpenAI par Mistral/Anthropic/Google ?

Oui : gardez la même architecture (cache + sanitation + erreurs), remplacez uniquement bpcab_call_openai_translation() par une fonction qui appelle leur endpoint avec wp_remote_post(). Ne changez pas le reste tant que votre format de sortie reste “JSON strict”.