إذا سبق لك أن قمت بلصق فقرة يدويًا في ترجمة جوجل في الساعة 23 مساءً لنشر نسخة إنجليزية بسرعة، فأنت تعرف أين تكمن المشكلة: إنها بطيئة وغير متناسقة، وينتهي بها الأمر إلى عملية نسخ ولصق في المحرر. WordPress بدون أي إمكانية للتتبع.

WordPress يُوفر الإصدار 6.9.4 (أبريل 2026) أدوات جيدة (واجهة برمجة تطبيقات REST، وبيانات مؤقتة، وخطافات)، ولكنه لا يُترجم أي شيء بشكل أصلي. الفكرة هنا بسيطة: ربط واجهة برمجة تطبيقات ترجمة الذكاء الاصطناعي عبر wp_remote_post()دون تثبيت أي إضافات، مع الحفاظ على التحكم في التخزين المؤقت والتكاليف والأمان.

الحاجة / حالة الاستخدام

المشكلة المحددة: لديك محتوى (مقالات(الصفحات، وأحيانًا حقول ACF) وتريد إنشاء نسخة مترجمة بسرعة وبجودة مقبولة، دون إضافة مكون إضافي ثقيل (ومكلف في كثير من الأحيان) للترجمة يقوم بتعديل قاعدة البيانات الخاصة بك ومكتبك الخلفي.

كثيراً ما رأيت هذه الحاجة لدى:

  • المدونات التقنية الفرنسية التي ترغب في نسخة إنجليزية "كافية" لتحسين محركات البحث للكلمات الطويلة،
  • مواقع عرض (Avada/Divi/Elementor) حيث يجب أن تحتوي على 10 صفحات بلغتين،
  • المواقع ذات المحتوى الثابت للغاية (المستندات، الصفحات القانونية)، حيث لا تتغير الترجمة تقريبًا.

في النهاية، ستعرف كيفية التنفيذ:

  • نقطة نهاية REST آمنة لطلب الترجمة من جانب المسؤول،
  • محرك ترجمة يعمل بالذكاء الاصطناعي (مثل OpenAI) عبر wp_remote_post(),
  • ذاكرة تخزين مؤقتة واحدة لكل منشور + لغة مع بيانات مؤقتة،
  • عرض فوري (بدون تكرار المنشورات) عبر فلتر على the_content (خياري)،
  • استراتيجية احتياطية فعّالة في حال كان أداء واجهة برمجة التطبيقات بطيئًا أو غير مستقر. خطأ.

ملخص سريع

  • تقوم بتخزين مفتاح API في wp-config.php (ليس في شكل ورقي أبدًا).
  • تقوم بإنشاء مكون إضافي مصغر (أو مكون إضافي متعدد) يعرض نقطة نهاية REST خاصة بالمسؤولين فقط.
  • يستدعي الملحق واجهة برمجة تطبيقات الذكاء الاصطناعي باستخدام wp_remote_post() + مهلة زمنية + معالجة الأخطاء.
  • يتم تخزين الترجمة مؤقتًا عبر set_transient() (لكل منشور/لغة + تجزئة المحتوى).
  • الخيار الأول: عرض فوري (بدون تكرار). الخيار الثاني: إنشاء البيانات وتخزينها في البيانات الوصفية.
  • يمكنك إضافة تحديد معدل الطلبات + تنظيف HTML (wp_kses_post()) لتجنب المفاجآت غير السارة.

متى يُستخدم الذكاء الاصطناعي لهذا الغرض؟

استخدم هذا الأسلوب عندما:

  • تريد ترجمة "جيدة" دون بناء بنية تحتية متعددة اللغات بالكامل،
  • المحتوى في معظمه نصي (مقالات، صفحات)،
  • تقبل ترجمة غير كاملة ولكنها متماسكة إذا قمت بتوفير مسرد للمصطلحات.
  • تريد التحكم في التكلفة (التخزين المؤقت المكثف، والترجمة عند الطلب)،
  • لا تريد الاعتماد على إضافة تفرض نموذج بياناتها الخاص.

في تجربتي، يعمل هذا بشكل جيد للغاية بالنسبة للمواقع التحريرية حيث تشكل المقالات 80٪ من الصفحات وحيث يتم استخدام الترجمة بشكل أساسي لجذب القراء (تحسين محركات البحث + القراء الدوليين)، وليس للتوطين القانوني الصارم.

متى لا يجب استخدام الذكاء الاصطناعي

تجنب استخدام الذكاء الاصطناعي (أو قلل من استخدامه) عندما:

  • لديك متطلبات قانونية (الشروط والأحكام، الطبية، المالية): الترجمة الآلية غير المدققة تشكل خطراً.
  • لديك بالفعل WPML/Polylang واستراتيجية متعددة اللغات حقيقية (عناوين URL لكل لغة، و hreflang، والقوائم، وما إلى ذلك).
  • أنت بحاجة إلى ترجمة سلاسل واجهة المستخدم (سلاسل السمات): أداة مخصصة (gettext) هي الأفضل.
  • أنت بحاجة إلى ترجمة المحتوى الديناميكي للغاية (التعليقات، المحتوى الذي ينشئه المستخدمون): التكلفة + اللائحة العامة لحماية البيانات + الإشراف.
  • لديك موقع كبير جدًا (أكثر من 10 آلاف منشور) وتعتقد أنه يمكنك "ترجمة كل شيء دفعة واحدة": ستهدئك الفاتورة والحصص.

بديل أبسط وأكثر تقليدية: إذا كان كل ما تحتاجه هو عرض محتوى مختلف حسب اللغة، فإن تكرار 10 صفحات وقائمة لكل لغة يدويًا قد يكون في بعض الأحيان الخيار الأمثل من حيث العائد على الاستثمار. يُعدّ الذكاء الاصطناعي مفيدًا بشكل أساسي عندما ترغب في أتمتة العمليات دون الحاجة إلى إعادة تصميم موقعك بالكامل.

المتطلبات الأساسية

إصدارات

  • WordPress 6.9.4+ (أبريل 2026)
  • PHP 8.1+ (يوصى باستخدام 8.2/8.3 إذا كان مزود الاستضافة الخاص بك يدعم ذلك)
  • يجب تفعيل بروتوكول HTTPS (وإلا فإن استدعاء واجهة برمجة التطبيقات سيكون فكرة سيئة).

مفتاح 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 أو كمتغير بيئي.

بنية الحل

التدفق (مخطط نصي):

لوحة تحكم ووردبريس ← واجهة برمجة تطبيقات REST (نقطة نهاية آمنة) ← wp_remote_post() → واجهة برمجة تطبيقات الذكاء الاصطناعي (الترجمة) → التحقق + التنظيف → التخزين المؤقت (مؤقت + خيار البيانات الوصفية) → إرجاع JSON → العرض (اختياري عبر عامل التصفية)

ما الذي يحدث وراء الكواليس؟

  • طبق رئيسي : A post_id، لغة مصدر، ولغة هدف، وربما "مسرد مصطلحات".
  • استخلاص نقوم باسترجاع المحتوى (والعنوان إذا أردت)، ثم نقوم بإعداده (مع الحفاظ على ترميز HTML).
  • مخبأ نحسب مفتاح التخزين المؤقت بناءً على تجزئة المحتوى + اللغة المستهدفة. إذا لم يتغير هذا، فلن ندفع مقابل واجهة برمجة التطبيقات.
  • استدعاء واجهة برمجة التطبيقات طلب JSON، مهلة زمنية معقولة، محاولات إعادة قليلة (بدون حلقة لا نهائية).
  • التنظيف لا نثق في كود HTML المُعاد. نحن نستخدم wp_kses_post().
  • سورتي : JSON للمسؤول، وعرض الواجهة الأمامية اختياريًا بناءً على معلمة اللغة.

الكود الكامل - خطوة بخطوة

سنقوم بإنشاء إضافة صغيرة. أوصي بـ مو البرنامج المساعد إذا كنت لا ترغب في أن يقوم العميل بتعطيله "لأغراض الاختبار". وإلا، فهو إضافة قياسية.

الخطوة 1 - إنشاء إضافة mu

Créez 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) إذا لزم الأمر.
  • عاد المطهر.
  • يمكن تخزينها اختيارياً في بيانات المنشور.
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إنه مناسب للاختبار، ولكنه ليس استراتيجية كاملة لتحسين محركات البحث متعددة اللغات.

أقوم بذلك غالبًا خلال مرحلة التحقق: يقوم العميل بالنقر والمقارنة، ثم نقوم بتعديل المصطلحات، وبعد ذلك فقط نقرر ما إذا كنا سنخزنها في صفحات التعريف أو الصفحات المكررة.

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 هي "نقطة دخول" مستقرة. يمكنك بعد ذلك:

  • استدعاء الترجمة من خلال برنامج نصي صغير مكتوب بلغة جافا سكريبت في لوحة التحكم.
  • إطلاق ترجمات مجمعة عبر WP-CLI (النسخة أدناه)،
  • ربط سير العمل التحريري.

والأهم من ذلك كله: أن REST يجبرك على إدارة الأذونات + nonce بشكل صحيح.

لماذا يتم استخدام ذاكرة التخزين المؤقت المؤقتة بدلاً من خيار أو ملف؟

يُعدّ العابر مناسبًا للأسباب التالية:

  • له تاريخ انتهاء صلاحية أصلي.
  • وهو متوافق مع ذاكرة التخزين المؤقت للكائنات (Redis/Memcached) إذا كان لديك واحدة.
  • فهو يمنع تلوث الطاولة postmeta لإجراء اختبارات سريعة.

عند الانتقال إلى بيئة إنتاج متعددة اللغات "جدية"، من المرجح أن تقوم بتخزين البيانات في علامات التعريف (أو إنشاء منشورات مترجمة). هنا، تعمل البيانات المؤقتة كضمان لتكاليفك.

Pourquoi wp_kses_post() بخصوص رد الذكاء الاصطناعي؟

لأنك لا تملك تحكمًا كاملًا في ما يُرجعه النموذج. حتى لو طلبت منه استخدام "JSON صارم"، فقد يتعطل النموذج، أو يُضيف وسومًا، أو "يُصلح" كود HTML الخاص بك عن طريق تعديله.

wp_kses_post() يطبق هذا الخيار قائمة العلامات المسموح بها في سياق ووردبريس. الوثائق الرسمية: wp_kses_post().

لماذا يوجد تجزئة للحمولة في مفتاح التخزين المؤقت؟

بدون استخدام التجزئة، سيتم تخزين "المنشور رقم 123 باللغة الإنجليزية" مؤقتًا، وسيتم عرض الترجمة نفسها حتى عند تعديل المنشور. أما التجزئة فتجعل ذاكرة التخزين المؤقت تستجيب للتغييرات، دون الحاجة إلى منطق تنظيف معقد.

تكاليف واجهة برمجة التطبيقات وتحسينها

تعتمد التكلفة على المورّد، والطراز، وخاصةً حجم النص. أنا أقدم لك طريقة حساب واقعية، وليست وعدًا.

تقدير عملي

  • غالباً ما يمثل منشور المدونة "المتوسط" (800-1200 كلمة) بضعة آلاف من الرموز المميزة بمجرد تسلسلها (HTML + الرموز المختصرة + الموجه).
  • إذا قمت بترجمة 100 مقال شهريًا، بدون استخدام التخزين المؤقت، فإنك تدفع مقابل 100 مكالمة كحد أدنى شهريًا.

أحرص عمداً على استخدام الصياغة العامة: التكلفة = عدد رموز الدخول × سعر الدخول + عدد رموز الخروج × سعر الخروجتتغير الأسعار باستمرار؛ لذا يُرجى مراجعة صفحة الأسعار الخاصة بالمورد.

التحسينات التي لها تأثير فوري:

  • ذاكرة تخزين مؤقتة طويلة (30 يومًا أو أكثر) ومفتاح يعتمد على تجزئة المحتوى.
  • نموذج "ميني" للترجمة (غالباً ما يكون ذلك كافياً).
  • قلل من التنبيه : لك system قد تكون أقصر بعد استقرارها.
  • قم بترجمة النسخة النهائية فقط تجنب إرسال نفس الكتلة 10 مرات (منشئ القوالب).

فخ التكلفة الذي أراه كثيراً

يقوم المستخدمون باختبار التطبيق على بيئة الإنتاج، حيث يقومون بتحديث الصفحة 30 مرة مع ?lang=enثم تتساءل عن سبب ارتفاع الفاتورة. بدون ذاكرة تخزين مؤقتة، يؤدي كل تحديث إلى استدعاء. مع ذاكرة مؤقتة + تجزئة، تستقر البيانات فورًا.

المتغيرات المتقدمة وحالات الاستخدام

الخيار الأول - ترجمة البيانات وتخزينها في البيانات الوصفية، ثم عرضها عبر عامل التصفية

إذا كنت ترغب في تجنب استدعاءات واجهة برمجة التطبيقات (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);

الخيار الثاني - المعالجة الدفعية عبر 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، وهو مشروع منفصل).
  • أفادا (منشئ الاندماج) ينطبق نفس المنطق على Divi، فهناك الكثير من الرموز المختصرة في Fusion. احتفظ بها لنفسك فقط، وإلا ستفقد التصميم.

إذا كان موقعك يعتمد بشكل أساسي على أدوات إنشاء المواقع، فإن الاستراتيجية الأكثر أمانًا هي: ترجمة فقط مربعات نصية (الأدوات/الوحدات) وليس البنية. هنا، أنت تتعامل مع تطوير محدد لكل أداة بناء.

السلامة وأفضل الممارسات

لا تكشف مفتاح API من جانب العميل

ممنوع استخدام جافا سكريبت لاستدعاء OpenAI/Anthropic مباشرةً. سيؤدي ذلك إلى وصول مفتاحك إلى المتصفح. يجب أن تمر جميع الطلبات عبر خادم ووردبريس الخاص بك.

الحد الأدنى لتحديد المعدل

يمكن اختراق نقطة نهاية 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.
  • قائمة أنواع المنشورات المسموح بها.
  • استخدام التعبيرات النمطية على رموز اللغة لتجنب مفاتيح التخزين المؤقت غير المألوفة.

اللائحة العامة لحماية البيانات / البيانات المرسلة إلى واجهة برمجة التطبيقات

إذا قمت بالترجمة:

  • بيانات المستخدم (التعليقات، النماذج)،
  • البيانات الحساسة (البريد الإلكتروني، العناوين)،

أنت ترسل هذه البيانات إلى طرف ثالث. قم بإجراء تدقيق: الأساس القانوني، وقانون حماية البيانات، وفترة الاحتفاظ بالبيانات، وإخفاء الهوية. الكود أعلاه لا يُخفي أي بيانات.

كيفية الاختبار والتصحيح

1) تفعيل التسجيل

في wp-config.php (في بيئة تجريبية):

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

وثيقة رسمية: تصحيح في وورد.

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 تجاوزت الحصة / حد سعر المورد Regarder details.status وجسم واجهة برمجة التطبيقات انتظر، اخفض مستوى الصوت، فعّل خاصية التخزين المؤقت، استخدم طرازًا أخف وزنًا
مهلة مهلة الانتظار قصيرة جدًا أو الخادم بطيء سجلات PHP + زيادة مؤقتة timeout قم بزيادة المدة إلى 40 ثانية في وضع الدُفعات، أو قم بالترجمة خارج الواجهة (WP-CLI).
HTML معطوب (منشئ التخطيط) تم تعديل الرموز المختصرة/السمات في النموذج قارن بين النص الأصلي والترجمة تحسين التوجيه، ترجمة مناطق النص فقط، التخزين حسب الوحدة
الترجمة لم تكن "مخفية" أبداً مفتاح ذاكرة تخزين مؤقت غير مستقر (محتوى مختلف في كل استدعاء) سجل قيمة التجزئة (في بيئة الاختبار) قم بتنظيف المحتوى قبل التجزئة (إزالة الكتل الديناميكية)، أو قم بتخزينه في البيانات الوصفية.
خطأ 403 على نقطة النهاية قيمة REST nonce مفقودة/غير صالحة أو سعة غير كافية تحقق من رأس الصفحة X-WP-Nonce + دور المستخدم قم بإنشاء قيمة عشوائية (nonce) بشكل صحيح، ثم اضبطها. permission_callback

أخطاء "سخيفة" ولكنها متكررة

  • تم لصق الكود في المكان الخطأ في جزء من إضافة برمجية يقوم بتصغير/تعديل ملفات PHP ويؤدي إلى كسر الترميز. يُفضل استخدام ملف mu-plugin.
  • فاصلة منقوطة مفقودة يظهر لك خطأ 500. انظر wp-content/debug.log.
  • خطاف غير لائق إذا حاولت استدعاء واجهة برمجة تطبيقات REST قبل rest_api_initلم يتم الإعلان عن أي شيء.
  • اختبار الإنتاج أنت تُطلق عشرات المكالمات المدفوعة. رتّب جهودك، ثم انتقل إلى النظام الجديد.
  • لغة PHP قديمة جدًا : كتابة + إرجاع : WP_REST_Response قد تتعطل هذه البرامج على PHP 7.x. نحن هنا نستهدف PHP 8.1+.

الموارد

الأسئلة الشائعة

هل هو حقاً "بدون إضافات"؟

نعم، بدون استخدام إضافة ترجمة خارجية. من الناحية التقنية، أنت لا تزال تضيف كودًا على شكل إضافة مخصصة (أو إضافة مخصصة). هذا مقصود: للحفاظ على التحكم وتجنب نظام معقد.

هل يؤدي هذا إلى إنشاء صفحات مترجمة بعناوين 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 صارم".