Ако някога сте поставяли JSON-LD Schema.org „на ръка“ в статия, значи сте виждали истинския проблем: след 30 публикации той е непоследователен, непълен и никой не смее да го поддържа.

Нуждата / Случаят на употреба

Структурираните данни (JSON-LD) помагат на Google и други търсачки да разберат точно съдържанието: тип статия, автор, дата на публикуване, основно изображение, обект „за нас“, ЧЗВ и др. WordPressВече разполагате с много надеждна информация (заглавие, откъс, представено изображение). Празнината е „значението“: теми, обекти, намерения и понякога правилното тип Schema.org (Article срещу TechArticle срещу NewsArticle и др.).

Изкуственият интелект е полезен тук, за да създаде семантичен слой въз основа на съдържанието, без Независимо дали прекарвате по 10 минути на статия, избирайки ключови думи, теми или секции „За нас“. Според моя опит това е особено печелившо при:

  • Технически блогове (WordPress, разработка, данни): Изкуственият интелект извлича добре технологии, версии, концепции.
  • Редакционни уебсайтове с много редактори: стандартизирайте маркировката, без да обучавате всички в Schema.org.
  • „Съдържание“ на сайтове за електронна търговия (ръководства, сравнения): обогатяване на статиите без промяна на описанията на продуктите.

В крайна сметка ще знаете как да внедрите плъгин (Съвместим с WordPress 6.9.4 / PHP 8.1+), който:

  • генерира JSON-LD Schema.org за всяка статия чрез AI API (извикване wp_remote_post())
  • кешира отговора (Transients API)
  • Регенерира се само когато е необходимо (публикация/актуализация).
  • инжектира JSON-LD в <head> отпред
  • Обработва грешки (таймаути, квота, невалиден JSON) с чисти резервни варианти

Бързо обобщение

  • Ние генерираме JSON-LD по пощата чрез изкуствен интелект, тогава ние запас в post meta (и поставяме a преходен (в допълнение към избягването на повтарящи се обаждания).
  • API ключът е в WP-config.php от define(), никога в постоянна форма.
  • Наричаме AI API с wp_remote_post() + изчакване + обработка на грешки.
  • Принуждаваме изкуствения интелект да изпрати обратно Строг JSON (и го валидираме от страна на PHP).
  • Инжектираме JSON-LD скрипта чрез wp_head (отпред) и избягваме администратора.
  • Добавяме REST крайна точка само за администратор за регенерират при поискване (практическо при осигуряване на качеството).

Кога да използваме изкуствен интелект за това

Използвайте изкуствен интелект, ако имате истинска нужда от семантично обогатяване, а не просто да „поставяте „статия“ навсякъде“. Добри случаи на употреба:

  • Дълго съдържание (1000+ думи), където извличането на обекти (марки, инструменти, концепции) носи прецизност.
  • Непълни таксономии (ненадеждни категории/етикети) и искате по-чисти полета „за/споменавания“.
  • Екипно писане с хетерогенни стилове: ИИ нормализира.
  • SEO миграция (нова тема, нов SEO плъгин): можете да генерирате последователна схема без да пренаписвате публикации.

Често съм виждал предимство в сайтове, където фрагментът на WordPress е празен и където авторите се променят много: изкуственият интелект създава последователно описание, което избягва произволния „фрагмент“.

Кога НЕ е добре да използвате изкуствен интелект

Избягвайте изкуствен интелект, ако вашата схема е чисто механична и вече детерминистична.

  • Представете уебсайтове с 10 страници: направете го ръчно или чрез SEO плъгин.
  • Прости диаграми (Организация, Уебсайт, BreadcrumbList) вече се управлява от вашия SEO плъгин.
  • Деликатно съдържание (здравеопазване, правни въпроси), ако разчитате на изкуствен интелект да „изобретява“ свойства. Тук изкуственият интелект трябва да остане добиващ, а не креативен.
  • Ограничен бюджет и голям обем публикации, публикувани всеки ден: разходите за API могат да се увеличат, ако регенерирате твърде често.

Класически анти-шаблон е извикването на изкуствения интелект всеки път, когато се показва страница. Това води до изчакване на времето, ненужни разходи и понякога празни страници, ако кодът е лошо защитен.

Предварителни

Целева среда: WordPress 6.9.4 (април 2026 г.) и PHP 8.1+.

API ключ и място за съхранение

Можете да използвате OpenAI, Anthropic, Mistral или Google. Ще предоставя пример за OpenAI (API отговори), защото е много стабилен откъм стриктно JSON отношение, но структурата на плъгина улеснява замяната на доставчика.

Съхранявайте ключа в wp-config.php (или още по-добре, променлива на средата, инжектирана от вашия доставчик на хостинг услуги). Пример:

/**
 * Clé API IA (ne jamais commiter ce fichier).
 * Idéalement, utilisez une variable d'environnement et fallback sur define().
 */
define('BPCAB_AI_OPENAI_API_KEY', 'REMPPLACEZ-MOI');

PHP разширения

  • къдрица (често е активирано) или allow_url_fopen (WordPress използва Requests, което разчита на cURL, ако е наличен).
  • JSON (стандартен).

Полезни официални източници

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

Текстов поток, използван от плъгина:

Éditeur WordPress (save_post) → préparation des données (titre, contenu, extrait, image, auteur) → wp_remote_post() vers API IA → réponse JSON → validation/sanitation → stockage post meta + transient → front (wp_head) injecte <script type=”application/ld+json”>…

Защо този работен процес работи добре в производствения процес

  • Генерация по време на спестяване (или при поискване), а не на дисплея: не блокирате рендерирането от предния край, ако AI ​​API е бавен.
  • Кеш : кратък преходен процес избягва циклични регенерации, когато редактор щракне върху „Актуализиране“ 5 пъти.
  • Метаданни на публикацията : постоянен, експортируем и версионируем (ако имате система за етапно разпределение).
  • JSON валидиране : ако ИИ върне счупен текст или JSON, нищо не се инжектира (резервен вариант).

Важна забележка: SEO плъгини и дубликати

Yoast, Rank Math, SEOPress и др. вече инжектират JSON-LD. Ако добавите свои собствени, рискувате:

  • дубликати (два Article)
  • несъответствия (двама автори, две изображения)

Стратегията, която препоръчвам: инжектиране на „допълваща“ схема (напр. about, mentions, keywords, audience) в едно Article които контролирате, или пък създавате @graph чист. Кодът по-долу генерира @graph минималистичен и избягва „преоткриването“ на организацията/уебсайта.

Пълният код — стъпка по стъпка

Съветвам те да го сложиш mu-плъгин Ако искате да оцелее при промени в темата и „случайни деактивации“. В противен случай, стандартен плъгин.

Стъпка 1 — Минимална структура на плъгина

Създайте файл: wp-content/mu-plugins/bpcab-ai-schema.php (създайте папката, ако е необходимо).

<?php
/**
 * Plugin Name: BPCAB AI Schema (JSON-LD)
 * Description: Génère et injecte des données structurées Schema.org via IA par article.
 * Version: 1.0.0
 * Requires at least: 6.9
 * Requires PHP: 8.1
 *
 * Conseil : placez ce fichier en mu-plugin pour éviter la désactivation accidentelle.
 */

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

Стъпка 2 — Константи, опции и предпазни мерки

Ние го защитаваме от самото начало: ако ключът не съществува, не опитваме нищо. Често съм виждал сайтове да връщат грешки 401 многократно, защото кодът е продължавал да се опитва въпреки липсващия ключ.

/**
 * Retourne la clé API OpenAI depuis wp-config.php.
 */
function bpcab_ai_schema_get_openai_key(): string {
	if (defined('BPCAB_AI_OPENAI_API_KEY') && is_string(BPCAB_AI_OPENAI_API_KEY) && BPCAB_AI_OPENAI_API_KEY !== '') {
		return BPCAB_AI_OPENAI_API_KEY;
	}
	return '';
}

/**
 * Petite liste de post types autorisés.
 * Ajustez selon votre site (ex: 'post', 'page', 'guide', etc.).
 */
function bpcab_ai_schema_allowed_post_types(): array {
	return array('post');
}

Стъпка 3 — Извличане на „надеждни“ данни от WordPress

Изкуственият интелект не трябва да измисля дати, автори или URL адреси. Взимаме ги от WordPress, след което искаме от изкуствения интелект само семантично обогатяване.

/**
 * Construit un paquet de données "source of truth" depuis WordPress.
 * On évite d'envoyer des données inutiles (coût + confidentialité).
 */
function bpcab_ai_schema_build_post_payload(int $post_id): array {
	$post = get_post($post_id);
	if (!$post) {
		return array();
	}

	$title   = get_the_title($post);
	$content = $post->post_content;

	// Option : limiter la taille envoyée à l'API (coût + latence).
	// Ici, on garde le contenu brut, mais vous pouvez préférer wp_strip_all_tags().
	$content_plain = wp_strip_all_tags($content);
	$content_plain = mb_substr($content_plain, 0, 12000); // garde-fou

	$excerpt = has_excerpt($post) ? $post->post_excerpt : wp_trim_words($content_plain, 55, '…');

	$author_id = (int) $post->post_author;
	$author_name = $author_id ? get_the_author_meta('display_name', $author_id) : '';

	$permalink = get_permalink($post);
	$published = get_the_date(DATE_W3C, $post);
	$modified  = get_the_modified_date(DATE_W3C, $post);

	$image_id = get_post_thumbnail_id($post);
	$image_url = '';
	if ($image_id) {
		$image = wp_get_attachment_image_src($image_id, 'full');
		if (is_array($image) && !empty($image[0])) {
			$image_url = $image[0];
		}
	}

	return array(
		'post_id'      => $post_id,
		'post_type'    => $post->post_type,
		'title'        => $title,
		'excerpt'      => $excerpt,
		'content'      => $content_plain,
		'permalink'    => $permalink,
		'datePublished'=> $published,
		'dateModified' => $modified,
		'authorName'   => $author_name,
		'image'        => $image_url,
		'language'     => get_bloginfo('language'),
	);
}

Стъпка 4 — AI подкана „strict JSON“ + API извикване чрез wp_remote_post()

Проблемът, който нарушава повечето имплементации: изкуственият интелект връща текст около JSON или несъвместими полета. Ние налагаме строг формат и след това валидираме.

Пример с OpenAI (крайна точка за отговор). Официална справка за API: API за отговори на OpenAI.

/**
 * Appelle OpenAI pour générer un JSON Schema.org (ou un fragment) basé sur le contenu.
 * Retourne un tableau PHP (décodé) ou WP_Error.
 */
function bpcab_ai_schema_call_openai(array $payload) {
	$api_key = bpcab_ai_schema_get_openai_key();
	if ($api_key === '') {
		return new WP_Error('bpcab_no_api_key', 'Clé API OpenAI manquante (BPCAB_AI_OPENAI_API_KEY).');
	}

	// Prompt : on demande un JSON STRICT, sans texte.
	$system = "Vous êtes un assistant spécialisé en SEO technique. Vous produisez uniquement du JSON strict, sans commentaire ni markdown.";
	$user = array(
		"Objectif: Générer un JSON-LD Schema.org pour un article WordPress.n"
		. "Contraintes:n"
		. "- Répondre uniquement avec un objet JSON valide.n"
		. "- Ne pas inventer d'URL, de dates, d'auteur.n"
		. "- Utiliser EXACTEMENT les valeurs fournies pour headline, url, datePublished, dateModified, author.name, image.n"
		. "- Ajouter des champs sémantiques utiles: keywords (array), about (array of Thing), mentions (array of Thing), articleSection (string), inLanguage.n"
		. "- Type recommandé: Article (ou TechArticle si le texte est technique).n"
		. "- Produire un JSON-LD avec @context et @graph.n"
		. "- Limiter keywords à 12 max. about/mentions: 8 max chacun.n"
		. "- Ne pas inclure Organization/WebSite si vous n'avez pas les données.nn"
		. "Données fiables (à utiliser telles quelles):n"
		. wp_json_encode(array(
			"headline" => $payload['title'] ?? '',
			"description" => $payload['excerpt'] ?? '',
			"url" => $payload['permalink'] ?? '',
			"datePublished" => $payload['datePublished'] ?? '',
			"dateModified" => $payload['dateModified'] ?? '',
			"authorName" => $payload['authorName'] ?? '',
			"image" => $payload['image'] ?? '',
			"inLanguage" => $payload['language'] ?? 'fr-FR',
		)) . "nn"
		. "Contenu (extrait):n"
		. ($payload['content'] ?? '')
	);

	$body = array(
		'model' => 'gpt-4.1-mini',
		'input' => array(
			array('role' => 'system', 'content' => $system),
			array('role' => 'user', 'content' => $user),
		),
		// Paramètres prudents : on veut du factuel, pas de créativité.
		'temperature' => 0.2,
		'max_output_tokens' => 900,
		// Demande explicite de sortie JSON. Selon l'API, ce champ peut évoluer.
		// Si OpenAI change, gardez la validation JSON côté PHP comme filet de sécurité.
		'text' => array('format' => array('type' => 'json_object')),
	);

	$args = array(
		'headers' => array(
			'Authorization' => 'Bearer ' . $api_key,
			'Content-Type'  => 'application/json',
		),
		'body' => wp_json_encode($body),
		'timeout' => 20, // évitez 60s : en front, c'est mort. Ici on est en save_post, mais restons raisonnables.
	);

	$response = wp_remote_post('https://api.openai.com/v1/responses', $args);

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

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

	if ($code < 200 || $code >= 300) {
		return new WP_Error('bpcab_openai_http_error', 'Erreur HTTP OpenAI: ' . $code, array('body' => $raw));
	}

	$data = json_decode($raw, true);
	if (!is_array($data)) {
		return new WP_Error('bpcab_openai_bad_json', 'Réponse OpenAI non JSON (impossible à décoder).', array('body' => $raw));
	}

	// Selon le format de Responses API, le texte peut être dans output[...].
	// On essaie d'extraire un bloc texte puis de décoder ce JSON.
	$json_text = '';

	// Extraction robuste (évite de dépendre d'un seul chemin).
	if (!empty($data['output']) && is_array($data['output'])) {
		foreach ($data['output'] as $item) {
			if (!is_array($item) || empty($item['content']) || !is_array($item['content'])) {
				continue;
			}
			foreach ($item['content'] as $content_item) {
				if (is_array($content_item) && ($content_item['type'] ?? '') === 'output_text' && isset($content_item['text'])) {
					$json_text .= $content_item['text'];
				}
			}
		}
	}

	$json_text = trim($json_text);
	if ($json_text === '') {
		// Fallback : parfois l'API peut renvoyer directement un champ text.
		if (isset($data['text']) && is_string($data['text'])) {
			$json_text = trim($data['text']);
		}
	}

	if ($json_text === '') {
		return new WP_Error('bpcab_openai_empty_output', 'Sortie OpenAI vide ou non trouvée.', array('body' => $raw));
	}

	$schema = json_decode($json_text, true);
	if (!is_array($schema)) {
		return new WP_Error('bpcab_schema_not_json', 'Le contenu généré n’est pas un JSON valide.', array('generated' => $json_text));
	}

	return $schema;
}

Стъпка 5 — Валидиране и саниране на JSON-LD

Не „дезинфекцирате“ JSON, както бихте направили с HTML. Правилният подход е да валидирате минималната структура, да премахнете всичко опасно (скриптове) и да кодирате правилно по време на показване.

Често срещан недостатък: използване wp_kses_post() върху JSON файл. Това прекъсва кавичките и прави JSON файла невалиден. Тук го валидираме като масив, след което wp_json_encode().

/**
 * Validation minimale du schéma.
 * On vérifie @context et @graph. On peut être plus strict selon vos besoins.
 */
function bpcab_ai_schema_validate(array $schema) {
	if (!isset($schema['@context']) || !is_string($schema['@context'])) {
		return new WP_Error('bpcab_schema_missing_context', 'Schema invalide: @context manquant.');
	}
	if (!isset($schema['@graph']) || !is_array($schema['@graph'])) {
		return new WP_Error('bpcab_schema_missing_graph', 'Schema invalide: @graph manquant.');
	}

	// Protection basique : on refuse toute tentative d'injection de balises.
	$encoded = wp_json_encode($schema);
	if ($encoded === false) {
		return new WP_Error('bpcab_schema_encode_failed', 'Impossible d’encoder le schéma en JSON.');
	}
	if (stripos($encoded, '<script') !== false || stripos($encoded, '</script') !== false) {
		return new WP_Error('bpcab_schema_script_detected', 'Contenu suspect détecté dans le schéma.');
	}

	return true;
}

/**
 * Nettoyage "pragmatique" : on limite certaines longueurs et on force des types.
 */
function bpcab_ai_schema_normalize(array $schema): array {
	// Limite de taille pour éviter un JSON-LD énorme (performance + crawl).
	$max_graph_items = 12;
	if (isset($schema['@graph']) && is_array($schema['@graph']) && count($schema['@graph']) > $max_graph_items) {
		$schema['@graph'] = array_slice($schema['@graph'], 0, $max_graph_items);
	}

	return $schema;
}

Стъпка 6 — преходен кеш + съхранение след мета данни

Комбинираме две нива:

  • мета данни за публикацията (постоянен) за преден дисплей
  • преходен (кратко), за да се избегне твърде бързото регенериране
/**
 * Clés de stockage.
 */
function bpcab_ai_schema_meta_key(): string {
	return '_bpcab_ai_schema_jsonld';
}
function bpcab_ai_schema_transient_key(int $post_id): string {
	return 'bpcab_ai_schema_lock_' . $post_id;
}

/**
 * Génère et stocke le schéma pour un post.
 */
function bpcab_ai_schema_generate_for_post(int $post_id) {
	$payload = bpcab_ai_schema_build_post_payload($post_id);
	if (empty($payload)) {
		return new WP_Error('bpcab_no_payload', 'Payload vide, post introuvable ?');
	}

	// Lock anti-boucle (ex: autosave + update en rafale).
	if (get_transient(bpcab_ai_schema_transient_key($post_id))) {
		return new WP_Error('bpcab_locked', 'Génération déjà en cours ou trop récente (lock transient).');
	}
	set_transient(bpcab_ai_schema_transient_key($post_id), 1, 2 * MINUTE_IN_SECONDS);

	$schema = bpcab_ai_schema_call_openai($payload);
	if (is_wp_error($schema)) {
		return $schema;
	}

	$valid = bpcab_ai_schema_validate($schema);
	if (is_wp_error($valid)) {
		return $valid;
	}

	$schema = bpcab_ai_schema_normalize($schema);

	// Stockage en post meta (tableau encodé JSON).
	$json = wp_json_encode($schema, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
	if ($json === false) {
		return new WP_Error('bpcab_encode_failed', 'Encodage JSON final impossible.');
	}

	update_post_meta($post_id, bpcab_ai_schema_meta_key(), $json);

	// On relâche le lock un peu plus tôt si tout s'est bien passé.
	delete_transient(bpcab_ai_schema_transient_key($post_id));

	return true;
}

Стъпка 7 — закачане на save_post (без да се нарушава работата на редактора)

Грешна закачалка или грешно условие и викате изкуствения интелект при автоматични запазвания, ревизии или прегледи на Elementor. Виждам го постоянно.

/**
 * Déclenchement à la sauvegarde.
 */
function bpcab_ai_schema_on_save_post(int $post_id, WP_Post $post, bool $update): void {
	// Éviter autosave, révisions, et contexte non pertinent.
	if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
		return;
	}

	// Éviter l'exécution sur les types non autorisés.
	if (!in_array($post->post_type, bpcab_ai_schema_allowed_post_types(), true)) {
		return;
	}

	// Éviter les brouillons: souvent le contenu est incomplet.
	// Ajustez selon votre workflow.
	if ($post->post_status !== 'publish') {
		return;
	}

	// Option : ne régénérer que si le contenu/titre a changé.
	// Ici, on régénère à chaque update publié (simple et fiable).
	$result = bpcab_ai_schema_generate_for_post($post_id);

	// On log en debug uniquement.
	if (is_wp_error($result) && defined('WP_DEBUG') && WP_DEBUG) {
		error_log('[BPCAB AI Schema] save_post error: ' . $result->get_error_code() . ' - ' . $result->get_error_message());
	}
}
add_action('save_post', 'bpcab_ai_schema_on_save_post', 20, 3);

Стъпка 8 — инжектиране на JSON-LD в wp_head

Инжектираме само във фронтенд, в единичните възли и само ако мета възелът съществува. Тук няма AI извиквания.

/**
 * Injecte le JSON-LD dans le head.
 */
function bpcab_ai_schema_print_jsonld(): void {
	if (is_admin()) {
		return;
	}
	if (!is_singular(bpcab_ai_schema_allowed_post_types())) {
		return;
	}

	$post_id = get_queried_object_id();
	if (!$post_id) {
		return;
	}

	$json = get_post_meta($post_id, bpcab_ai_schema_meta_key(), true);
	if (!is_string($json) || $json === '') {
		return;
	}

	// Vérification finale : JSON valide.
	$decoded = json_decode($json, true);
	if (!is_array($decoded)) {
		return;
	}

	// Encodage propre pour éviter les surprises.
	$out = wp_json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
	if ($out === false) {
		return;
	}

	echo "<script type="application/ld+json">n";
	echo $out;
	echo "n</script>n";
}
add_action('wp_head', 'bpcab_ai_schema_print_jsonld', 99);

Стъпка 9 — REST крайна точка за регенериране при поискване (само за администратор)

Много полезно, когато редактор каже, че „схемата не се показва“ и искате да я регенерирате, без да запазвате публикацията отново (и без да давате достъп до кода). Ние я защитаваме с capabilities + nonce.

/**
 * Enregistre une route REST pour régénérer le schéma.
 */
function bpcab_ai_schema_register_rest_route(): void {
	register_rest_route('bpcab/v1', '/schema/regenerate/(?P<id>d+)', array(
		'methods' => 'POST',
		'permission_callback' => function (WP_REST_Request $request) {
			// Nonce REST standard: X-WP-Nonce (wp_create_nonce('wp_rest')).
			if (!is_user_logged_in()) {
				return false;
			}
			return current_user_can('edit_posts');
		},
		'callback' => function (WP_REST_Request $request) {
			$post_id = (int) $request['id'];
			if ($post_id <= 0) {
				return new WP_REST_Response(array('ok' => false, 'error' => 'ID invalide'), 400);
			}

			$post = get_post($post_id);
			if (!$post) {
				return new WP_REST_Response(array('ok' => false, 'error' => 'Post introuvable'), 404);
			}

			if (!current_user_can('edit_post', $post_id)) {
				return new WP_REST_Response(array('ok' => false, 'error' => 'Accès refusé'), 403);
			}

			$result = bpcab_ai_schema_generate_for_post($post_id);
			if (is_wp_error($result)) {
				return new WP_REST_Response(array(
					'ok' => false,
					'error' => $result->get_error_message(),
					'code' => $result->get_error_code(),
					'data' => $result->get_error_data(),
				), 500);
			}

			return new WP_REST_Response(array('ok' => true), 200);
		},
	));
}
add_action('rest_api_init', 'bpcab_ai_schema_register_rest_route');

Пълният асемблиран код

Копирайте и поставете този файл такъв, какъвто е, в wp-content/mu-plugins/bpcab-ai-schema.phpСлед това добавете константата към wp-config.phpНе тествайте това първо в продукционна среда без резервно копие: една забравена скоба и сайтът е недостъпен. бял екран.

<?php
/**
 * Plugin Name: BPCAB AI Schema (JSON-LD)
 * Description: Génère et injecte des données structurées Schema.org via IA par article.
 * Version: 1.0.0
 * Requires at least: 6.9
 * Requires PHP: 8.1
 */

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

function bpcab_ai_schema_get_openai_key(): string {
	if (defined('BPCAB_AI_OPENAI_API_KEY') && is_string(BPCAB_AI_OPENAI_API_KEY) && BPCAB_AI_OPENAI_API_KEY !== '') {
		return BPCAB_AI_OPENAI_API_KEY;
	}
	return '';
}

function bpcab_ai_schema_allowed_post_types(): array {
	return array('post');
}

function bpcab_ai_schema_meta_key(): string {
	return '_bpcab_ai_schema_jsonld';
}

function bpcab_ai_schema_transient_key(int $post_id): string {
	return 'bpcab_ai_schema_lock_' . $post_id;
}

function bpcab_ai_schema_build_post_payload(int $post_id): array {
	$post = get_post($post_id);
	if (!$post) {
		return array();
	}

	$title   = get_the_title($post);
	$content = $post->post_content;

	$content_plain = wp_strip_all_tags($content);
	$content_plain = mb_substr($content_plain, 0, 12000);

	$excerpt = has_excerpt($post) ? $post->post_excerpt : wp_trim_words($content_plain, 55, '…');

	$author_id = (int) $post->post_author;
	$author_name = $author_id ? get_the_author_meta('display_name', $author_id) : '';

	$permalink = get_permalink($post);
	$published = get_the_date(DATE_W3C, $post);
	$modified  = get_the_modified_date(DATE_W3C, $post);

	$image_id = get_post_thumbnail_id($post);
	$image_url = '';
	if ($image_id) {
		$image = wp_get_attachment_image_src($image_id, 'full');
		if (is_array($image) && !empty($image[0])) {
			$image_url = $image[0];
		}
	}

	return array(
		'post_id'       => $post_id,
		'post_type'     => $post->post_type,
		'title'         => $title,
		'excerpt'       => $excerpt,
		'content'       => $content_plain,
		'permalink'     => $permalink,
		'datePublished' => $published,
		'dateModified'  => $modified,
		'authorName'    => $author_name,
		'image'         => $image_url,
		'language'      => get_bloginfo('language'),
	);
}

function bpcab_ai_schema_call_openai(array $payload) {
	$api_key = bpcab_ai_schema_get_openai_key();
	if ($api_key === '') {
		return new WP_Error('bpcab_no_api_key', 'Clé API OpenAI manquante (BPCAB_AI_OPENAI_API_KEY).');
	}

	$system = "Vous êtes un assistant spécialisé en SEO technique. Vous produisez uniquement du JSON strict, sans commentaire ni markdown.";
	$user = array(
		"Objectif: Générer un JSON-LD Schema.org pour un article WordPress.n"
		. "Contraintes:n"
		. "- Répondre uniquement avec un objet JSON valide.n"
		. "- Ne pas inventer d'URL, de dates, d'auteur.n"
		. "- Utiliser EXACTEMENT les valeurs fournies pour headline, url, datePublished, dateModified, author.name, image.n"
		. "- Ajouter des champs sémantiques utiles: keywords (array), about (array of Thing), mentions (array of Thing), articleSection (string), inLanguage.n"
		. "- Type recommandé: Article (ou TechArticle si le texte est technique).n"
		. "- Produire un JSON-LD avec @context et @graph.n"
		. "- Limiter keywords à 12 max. about/mentions: 8 max chacun.n"
		. "- Ne pas inclure Organization/WebSite si vous n'avez pas les données.nn"
		. "Données fiables (à utiliser telles quelles):n"
		. wp_json_encode(array(
			"headline" => $payload['title'] ?? '',
			"description" => $payload['excerpt'] ?? '',
			"url" => $payload['permalink'] ?? '',
			"datePublished" => $payload['datePublished'] ?? '',
			"dateModified" => $payload['dateModified'] ?? '',
			"authorName" => $payload['authorName'] ?? '',
			"image" => $payload['image'] ?? '',
			"inLanguage" => $payload['language'] ?? 'fr-FR',
		)) . "nn"
		. "Contenu (extrait):n"
		. ($payload['content'] ?? '')
	);

	$body = array(
		'model' => 'gpt-4.1-mini',
		'input' => array(
			array('role' => 'system', 'content' => $system),
			array('role' => 'user', 'content' => $user),
		),
		'temperature' => 0.2,
		'max_output_tokens' => 900,
		'text' => array('format' => array('type' => 'json_object')),
	);

	$args = array(
		'headers' => array(
			'Authorization' => 'Bearer ' . $api_key,
			'Content-Type'  => 'application/json',
		),
		'body' => wp_json_encode($body),
		'timeout' => 20,
	);

	$response = wp_remote_post('https://api.openai.com/v1/responses', $args);
	if (is_wp_error($response)) {
		return $response;
	}

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

	if ($code < 200 || $code >= 300) {
		return new WP_Error('bpcab_openai_http_error', 'Erreur HTTP OpenAI: ' . $code, array('body' => $raw));
	}

	$data = json_decode($raw, true);
	if (!is_array($data)) {
		return new WP_Error('bpcab_openai_bad_json', 'Réponse OpenAI non JSON (impossible à décoder).', array('body' => $raw));
	}

	$json_text = '';
	if (!empty($data['output']) && is_array($data['output'])) {
		foreach ($data['output'] as $item) {
			if (!is_array($item) || empty($item['content']) || !is_array($item['content'])) {
				continue;
			}
			foreach ($item['content'] as $content_item) {
				if (is_array($content_item) && ($content_item['type'] ?? '') === 'output_text' && isset($content_item['text'])) {
					$json_text .= $content_item['text'];
				}
			}
		}
	}
	$json_text = trim($json_text);
	if ($json_text === '' && isset($data['text']) && is_string($data['text'])) {
		$json_text = trim($data['text']);
	}

	if ($json_text === '') {
		return new WP_Error('bpcab_openai_empty_output', 'Sortie OpenAI vide ou non trouvée.', array('body' => $raw));
	}

	$schema = json_decode($json_text, true);
	if (!is_array($schema)) {
		return new WP_Error('bpcab_schema_not_json', 'Le contenu généré n’est pas un JSON valide.', array('generated' => $json_text));
	}

	return $schema;
}

function bpcab_ai_schema_validate(array $schema) {
	if (!isset($schema['@context']) || !is_string($schema['@context'])) {
		return new WP_Error('bpcab_schema_missing_context', 'Schema invalide: @context manquant.');
	}
	if (!isset($schema['@graph']) || !is_array($schema['@graph'])) {
		return new WP_Error('bpcab_schema_missing_graph', 'Schema invalide: @graph manquant.');
	}

	$encoded = wp_json_encode($schema);
	if ($encoded === false) {
		return new WP_Error('bpcab_schema_encode_failed', 'Impossible d’encoder le schéma en JSON.');
	}
	if (stripos($encoded, '<script') !== false || stripos($encoded, '</script') !== false) {
		return new WP_Error('bpcab_schema_script_detected', 'Contenu suspect détecté dans le schéma.');
	}

	return true;
}

function bpcab_ai_schema_normalize(array $schema): array {
	$max_graph_items = 12;
	if (isset($schema['@graph']) && is_array($schema['@graph']) && count($schema['@graph']) > $max_graph_items) {
		$schema['@graph'] = array_slice($schema['@graph'], 0, $max_graph_items);
	}
	return $schema;
}

function bpcab_ai_schema_generate_for_post(int $post_id) {
	$payload = bpcab_ai_schema_build_post_payload($post_id);
	if (empty($payload)) {
		return new WP_Error('bpcab_no_payload', 'Payload vide, post introuvable ?');
	}

	if (get_transient(bpcab_ai_schema_transient_key($post_id))) {
		return new WP_Error('bpcab_locked', 'Génération déjà en cours ou trop récente (lock transient).');
	}
	set_transient(bpcab_ai_schema_transient_key($post_id), 1, 2 * MINUTE_IN_SECONDS);

	$schema = bpcab_ai_schema_call_openai($payload);
	if (is_wp_error($schema)) {
		return $schema;
	}

	$valid = bpcab_ai_schema_validate($schema);
	if (is_wp_error($valid)) {
		return $valid;
	}

	$schema = bpcab_ai_schema_normalize($schema);

	$json = wp_json_encode($schema, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
	if ($json === false) {
		return new WP_Error('bpcab_encode_failed', 'Encodage JSON final impossible.');
	}

	update_post_meta($post_id, bpcab_ai_schema_meta_key(), $json);
	delete_transient(bpcab_ai_schema_transient_key($post_id));

	return true;
}

function bpcab_ai_schema_on_save_post(int $post_id, WP_Post $post, bool $update): void {
	if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
		return;
	}
	if (!in_array($post->post_type, bpcab_ai_schema_allowed_post_types(), true)) {
		return;
	}
	if ($post->post_status !== 'publish') {
		return;
	}

	$result = bpcab_ai_schema_generate_for_post($post_id);
	if (is_wp_error($result) && defined('WP_DEBUG') && WP_DEBUG) {
		error_log('[BPCAB AI Schema] save_post error: ' . $result->get_error_code() . ' - ' . $result->get_error_message());
	}
}
add_action('save_post', 'bpcab_ai_schema_on_save_post', 20, 3);

function bpcab_ai_schema_print_jsonld(): void {
	if (is_admin()) {
		return;
	}
	if (!is_singular(bpcab_ai_schema_allowed_post_types())) {
		return;
	}

	$post_id = get_queried_object_id();
	if (!$post_id) {
		return;
	}

	$json = get_post_meta($post_id, bpcab_ai_schema_meta_key(), true);
	if (!is_string($json) || $json === '') {
		return;
	}

	$decoded = json_decode($json, true);
	if (!is_array($decoded)) {
		return;
	}

	$out = wp_json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
	if ($out === false) {
		return;
	}

	echo "<script type="application/ld+json">n";
	echo $out;
	echo "n</script>n";
}
add_action('wp_head', 'bpcab_ai_schema_print_jsonld', 99);

function bpcab_ai_schema_register_rest_route(): void {
	register_rest_route('bpcab/v1', '/schema/regenerate/(?P<id>d+)', array(
		'methods' => 'POST',
		'permission_callback' => function (WP_REST_Request $request) {
			if (!is_user_logged_in()) {
				return false;
			}
			return current_user_can('edit_posts');
		},
		'callback' => function (WP_REST_Request $request) {
			$post_id = (int) $request['id'];
			if ($post_id <= 0) {
				return new WP_REST_Response(array('ok' => false, 'error' => 'ID invalide'), 400);
			}

			$post = get_post($post_id);
			if (!$post) {
				return new WP_REST_Response(array('ok' => false, 'error' => 'Post introuvable'), 404);
			}

			if (!current_user_can('edit_post', $post_id)) {
				return new WP_REST_Response(array('ok' => false, 'error' => 'Accès refusé'), 403);
			}

			$result = bpcab_ai_schema_generate_for_post($post_id);
			if (is_wp_error($result)) {
				return new WP_REST_Response(array(
					'ok' => false,
					'error' => $result->get_error_message(),
					'code' => $result->get_error_code(),
					'data' => $result->get_error_data(),
				), 500);
			}

			return new WP_REST_Response(array('ok' => true), 200);
		},
	));
}
add_action('rest_api_init', 'bpcab_ai_schema_register_rest_route');

Обяснение на кода

Защо да съхранявате в мета данни на публикацията?

Метаданните на публикацията осигуряват стабилно състояние. Ако AI API се повреди, вашият JSON-LD ще продължи да се обслужва. А ако имате кеш на страницата (Varnish, плъгин за кеширане), избягвате колебанията.

Защо допълнително временно „заключване“?

В сайтове, използващи Elementor или Divi, действие „Актуализиране“ може да задейства множество запазвания (автоматично запазване, редакция, актуализиране). Дори ако филтрирате за автоматично запазване/редакция, съм виждал двойни извиквания чрез плъгини, които „презапазват“ публикацията. Преходният процес предотвратява двойното фактуриране.

Защо валидирането е умишлено минимално?

Schema.org е обширен. Ако валидирате твърде стриктно, ще нарушите полезни подобрения (напр. about en Thing vs DefinedTermТук просто проверяваме инвариантите (@context, @graph) и отхвърляме подозрително съдържание.

Защо не използваме wp_kses_post() върху JSON?

wp_kses_post() е HTML филтър. Когато се приложи към JSON, той нарушава символите и прави JSON невалиден. Вместо това, ние запазваме PHP масив, проверяваме структурата му и след това кодираме с wp_json_encode().

Реалистични грешки, които често виждам

  • Кодът е поставен във functions.php От родителска тема: актуализиране на темата = загубен код. Използвайте mu-plugin.
  • Забравяне на точката и запетаята в wp-config.php après define() → Незабавна фатална грешка.
  • Неподходяща кука (Например, the_content) → AI извикване за рендериране → латентност + разходи.
  • Производствено тестване без ограничаване на типа на публикацията → регенерирате 2000 публикации наведнъж чрез цикъл на запазване.

Цени и оптимизация на API

Цената зависи от модела и размера на изпратеното съдържание. С ограничение от 12 000 знака текст (което често представлява 2000–3000 думи без HTML), заявката ви е умерена.

Реалистична оценка (от порядъка на величината)

  • 1 статия = 1 AI заявка за публикация + 1 заявка за всяка значителна актуализация.
  • Ако публикувате 30 статии/месец и актуализирате всяка от тях средно 2 пъти: ~90 обаждания/месец.

За точни цени, моля, вижте официалните страници (те се променят). OpenAI: Ценообразуване на OpenAI.

Оптимизации, които действително работят

  • Намалете входа Изпратете откъса + заглавията H2/H3, а не цялото съдържание (ако съдържанието ви е много дълго).
  • Модел „Мини“ повече от достатъчно, за да извлече ключови думи/относно/споменавания.
  • Условна регенерация : сравнява хеш на съдържанието (мета на публикацията) и регенерира само ако хешът се промени.
  • Групово офлайн изтегляне (WP-CLI) за миграции вместо групово save_post.

Разширени варианти и случаи на употреба

Вариант 1 — регенерира се само ако съдържанието се е променило (хеш)

За да избегнете плащането, когато някой поправи запетая в заглавието, съхранявайте хеш.

function bpcab_ai_schema_hash_meta_key(): string {
	return '_bpcab_ai_schema_content_hash';
}

function bpcab_ai_schema_should_regenerate(int $post_id, array $payload): bool {
	$hash = hash('sha256', ($payload['title'] ?? '') . '|' . ($payload['excerpt'] ?? '') . '|' . ($payload['content'] ?? ''));
	$old  = get_post_meta($post_id, bpcab_ai_schema_hash_meta_key(), true);

	if (!is_string($old) || $old === '') {
		update_post_meta($post_id, bpcab_ai_schema_hash_meta_key(), $hash);
		return true;
	}

	if (!hash_equals($old, $hash)) {
		update_post_meta($post_id, bpcab_ai_schema_hash_meta_key(), $hash);
		return true;
	}

	return false;
}

Вариант 2 — Съвместимост с Divi 5 / Elementor / Avada

Тези конструктори често съхраняват съдържанието в post_content с вътрешни кратки кодове/JSON. Ако изпратите това такова, каквото е, на изкуствения интелект, той може да извлече артефакти.

  • Диви 5 Понякога ще виждате вътрешни структури. wp_strip_all_tags() помощ, но не винаги.
  • Elementor Част от съдържанието е в метаданни (данни от Elementor). Крайното рендиране е по-точно от суровата версия.
  • Avada Шорткодовете на Fusion Builder, същият проблем.

Два подхода:

  • „Безопасен“ подход (препоръчително): извличане само на видимия текст чрез the_content филтрирано, след което премахнете етикетите.
  • „Бърз“ подход : запази post_content и приемам шума.

„Безопасна“ версия (внимавайте да не правите това в цикъл в списъци, запазете я за save_post):

function bpcab_ai_schema_get_rendered_text(WP_Post $post): string {
	// Applique les filtres (shortcodes, blocs, builders) pour obtenir un HTML proche du front.
	$html = apply_filters('the_content', $post->post_content);

	// Supprime scripts/styles éventuels.
	$html = preg_replace('#<scriptb[^>]*>.*?</script>#is', '', $html ?? '');
	$html = preg_replace('#<styleb[^>]*>.*?</style>#is', '', $html ?? '');

	$text = wp_strip_all_tags($html);
	return mb_substr($text, 0, 12000);
}

Вариант 3 — добавете страница с често задавани въпроси, ако статията съдържа раздел с често задавани въпроси

Ако статиите ви често завършват с „ЧЗВ“, изкуственият интелект може да открие двойки въпроси и отговори. Но бъдете стриктни: изфабрикуваният ЧЗВ представлява SEO и редакционен риск. Препоръчвам генерирането на ЧЗВ само ако съдържанието вече съдържа изрични въпроси.

Можете да добавите ограничение към подканата: „извличайте само въпроси, които вече са налични дума по дума“.

Безопасност и най-добри практики

Никога не разкривайте ключа от страната на клиента

Абсолютно избягвайте да правите JavaScript извиквания от браузъра към AI API. Ключът ще изтече (DevTools, изходен код, лог файлове). Тук всичко минава през PHP чрез wp_remote_post().

Ограничаване на скоростта

Временното „заключване“ е начало. Ако имате сайт с множество автори, добавете ограничение за честота на потребител (напр. едно временно заключване на потребителски идентификатор) в крайната точка на REST.

Валидиране на запис

Не позволявайте на потребителя да инжектира произволен текст, изпратен до изкуствения интелект чрез неконтролиран REST параметър. Тук крайната точка приема post_id и възстанови полезния товар от WordPress.

GDPR / поверителност

  • Не изпращайте ненужни лични данни (имейли, IP адреси, лични полета).
  • Избягвайте изпращането на коментари или формуляри без ясно правно основание.
  • Документирайте подизпълнителя (OpenAI/Anthropic/и др.) във вашия регистър и политика за поверителност, ако е необходимо.

Съвместимост на кеша

Ако използвате агресивен кеш на страници, JSON-LD се инжектира чрез wp_head Ще бъде кеширано като всичко останало. Това е предназначението. Проблемът е да се регенерират метаданните и да се забрави да се изчисти кешът (кеш на плъгини/CDN). В този случай ще виждате старата схема с часове.

Как да тествате и отстранявате грешки

1) Първо локален тест

Активиране WP_DEBUG et WP_DEBUG_LOG в wp-config.php. Референция: Отстраняване на грешки в WordPress.

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

2) Проверете дали JSON-LD се извежда правилно

  • Отворете страница със статия.
  • Вижте изходния код.
  • Търсене application/ld+json.

3) Тестване на крайната точка на REST (регенериране)

От вашия браузър (след като сте влезли като администратор), можете да извикате чрез fetch в конзолата или чрез curl, използвайки nonce. Пример за curl (ако извличате nonce в администраторския панел):

curl -X POST "https://example.com/wp-json/bpcab/v1/schema/regenerate/123" 
  -H "X-WP-Nonce: VOTRE_NONCE" 
  -H "Content-Type: application/json"

4) Валидирайте JSON-LD

Използвайте валидатора на Schema.org или инструментите за тестване на богати резултати. Не ви давам линк към „SEO блог“, а структурирани препратки:

Ако това не проработи

Когато се повреди, почти винаги е една от тези причини: ключ, квота, невалиден JSON или твърде често задействана кука.

симптом Вероятна причина проверка Решение
Няма JSON-LD скрипт в изходния код Празни метаданни (никога не се генерират) или post_status ≠ публикуване Вижте мета _bpcab_ai_schema_jsonld (чрез плъгин за отстраняване на грешки) Публикувайте статията, след което я генерирайте отново чрез REST крайната точка
Логове: HTTP 401 / 403 Липсващ/неправилен API ключ WP_DEBUG_LOG, код на грешка в debug.log Правилно BPCAB_AI_OPENAI_API_KEY в wp-config.php
Дневници: време за изчакване Бавен API / Доставчикът на хостинг блокира изходящите заявки Тест wp_remote_get() към публичен сайт Увеличете леко timeoutПроверете настройките на защитната си стена и разрешете api.openai.com.
Грешка: „Генерираното съдържание не е валиден JSON“ Изкуственият интелект върна текст около JSON файла Инспектирайте generated в грешката (debug) Направете подканата по-строга, спазвайте text.formatнамаляване на температурата
Две диаграми. Статия на страницата. SEO плъгинът вече инжектира статия HTML източник: търсене на няколко "@type":"Article" Променете схемата си на „фрагмент“ или деактивирайте изхода за статии на SEO плъгина, ако е възможно.

Специфични клопки на WordPress

  • Фрагмент, повреден от плъгин за фрагменти Някои плъгини променят реда на зареждане. Използвайки mu-plugins, намалявате този риск.
  • Версията на PHP е твърде стара Ако сайтът все още работи на PHP 7.x, ще получавате грешки при въвеждане. Стремете се към PHP 8.1+.
  • Приоритет на куката Ако друг плъгин променя съдържанието след вашия save_post, вашата схема може да е „изоставаща“. Променете приоритета (20 → 30) или регенерирайте чрез крайна точка.

ресурси

ЧЗВ

Google автоматично ли „възнаграждава“ структурираните данни, генерирани от изкуствен интелект?

Не. Маркирането помага за интерпретацията, но ако съдържанието не е последователно, няма да имате полза. Истинската полза е, че съгласуваност и точност в голям мащаб.

Рисковано ли е да се инжектира JSON-LD, създаден от модел?

Да, ако позволите на изкуствения интелект да изобретява. Ето защо кодът налага използването на стойности на WordPress за критични полета и валидира структурата преди инжектиране.

Мога ли да използвам Anthropic или Mistral вместо това?

Да. Запазете същата архитектура: стриктно JSON подкани, wp_remote_post()валидиране json_decode()Променя се само форматът на заявка/отговор.

Защо да не генерирате пълна схема за организация/уебсайт/списък с навигационна верига?

Тъй като тези елементи често вече се управляват от SEO плъгин и тъй като са за целия сайт (не за всяка статия), смесването на два източника създава несъответствия.

Как да избегнем дублиране с Yoast/Rank Math/SEOPress?

Два реалистични варианта:

  • генерирайте схема, която не дублира статията (напр. DefinedTermSet или Thing свързани)
  • Деактивирайте изхода Schema на SEO плъгина (ако съществува опцията) и оставете вашия плъгин да управлява статията.

Мога ли да генерирам схемата за страниците (тип публикация страница)?

Да, но не насилвайте „Статия“. На някои страници можете да помолите изкуствения интелект да избира между WebPage, AboutPage, ContactPageДобавяне page в bpcab_ai_schema_allowed_post_types() и коригирайте подканата.

Защо диаграмата ми не се показва на страница на Elementor?

Често, защото тествате визуализация или шаблон, а не is_singular() класически. Тествайте крайния публичен URL адрес, след което проверете източника. Ако съдържанието е „празно“ от страната на post_content, използвайте варианта „рендериран текст“, базиран на apply_filters('the_content', ...).

Мога ли да покажа диаграмата в администраторския панел за проверка?

Да. Добавете метабокс само за четене, който показва JSON файла. Избягвайте да правите това поле редактируемо, в противен случай губите контрол върху валидирането.

Какво трябва да направя, ако API понякога връща невалиден JSON?

Намалете temperatureПодобрете подканата („отговаряйте само с JSON обект“) и запазете валидирането от страна на PHP. В продукционна среда предпочитам „без схема“ пред неработеща схема.

Как да мигрирам стар уебсайт с 2000 статии?

Не задействайте 2000 архивирания. Добавете WP-CLI команда или пакетен скрипт, който обработва архивиранията на партиди (50 публикации), с пауза и който спазва ограничението на скоростта. Ако желаете, мога да ви предоставя WP-CLI версия, базирана на WP_CLI::add_command() адаптиран за този плъгин.