Si vous avez déjà collé un bout de code “WordPress dans WordPress” et fini avec une page blanche ou un Fatal error, c’est presque toujours le même problème : vous avez chargé WordPress au mauvais endroit, ou vous l’avez chargé deux fois. Le besoin derrière ce sujet est pourtant très concret : exécuter du code WordPress (requêtes, création d’articles, lecture d’options, envoi d’emails, etc.) depuis un contexte qui n’est pas “un écran WordPress classique”.

En avril 2026, avec WordPress 6.9.4 et PHP 8.1+, la façon “efficace” de faire du WordPress dans WordPress consiste rarement à inclure wp-load.php à la main. Vous avez de meilleures options : WP-CLI, un endpoint REST, une tâche cron, un plugin mu-plugin, ou un script bootstrap propre qui charge WordPress une seule fois et utilise les API natives.

Le problème / Le besoin

Vous voulez déclencher des actions WordPress depuis WordPress, mais pas forcément “dans une page” :

  • importer des données depuis un CSV en tâche de fond,
  • nettoyer des métadonnées,
  • générer des images,
  • publier des brouillons en lot,
  • exposer un mini-service interne (ex : recalcul d’un cache applicatif),
  • exécuter un script ponctuel sans ouvrir l’admin.

À la fin, vous saurez choisir le bon mécanisme (WP-CLI, REST, Cron, script bootstrap), et vous aurez un code copiable-collable qui :

  • charge WordPress correctement (si nécessaire),
  • protège l’exécution (capabilities + nonce / token),
  • évite les doubles chargements et les pièges de performance,
  • fonctionne sur WordPress 6.9.4 / PHP 8.1+.

Résumé rapide

  • On évite d’inclure WordPress “au hasard” (le classique require wp-load.php depuis le web).
  • On privilégie WP-CLI pour les jobs ponctuels/administratifs (le plus fiable).
  • On propose un endpoint REST sécurisé pour déclencher une action depuis l’extérieur (ou depuis un builder via un bouton).
  • On ajoute une tâche WP-Cron pour décaler le traitement et limiter les timeouts.
  • On fournit un mu-plugin complet (copier-coller) + une commande WP-CLI optionnelle.

Quand utiliser cette solution

  • Vous avez besoin d’un “outil interne” (rebuild cache, synchronisation, import, maintenance).
  • Vous ne voulez pas dépendre d’un plugin de snippets fragile (j’ai souvent vu des sites cassés après une mise à jour de thème enfant qui écrase functions.php).
  • Vous devez déclencher un traitement depuis Elementor/Divi/Avada via un bouton ou une requête AJAX/REST.
  • Vous avez besoin d’un traitement asynchrone (déclenchement rapide, traitement plus long derrière).

Quand ne PAS utiliser cette solution

  • Vous voulez afficher des contenus dans une page : utilisez plutôt un bloc, un shortcode, un widget, ou un pattern. Charger WordPress “dans WordPress” n’a aucun sens dans ce cas.
  • Vous voulez faire un import massif (100k+ lignes) sur un hébergement mutualisé : WP-Cron + REST peut suffire, mais le vrai bon choix est souvent WP-CLI (ou un worker externe).
  • Vous avez un besoin “éditeur” (mise en page, composition) : utilisez les outils natifs (éditeur de blocs) ou les modules des builders. Ne transformez pas un besoin de contenu en besoin de code.
  • Vous cherchez une API publique : un endpoint REST peut convenir, mais il faut alors gérer auth, quotas, logs, et parfois un WAF. Sinon, passez par une vraie API gateway.

Prérequis / avant de commencer

  • WordPress 6.9.4 (ou plus récent) et PHP 8.1+.
  • Un environnement de test (staging) et une sauvegarde. Tester un script de maintenance sur la prod sans snapshot, c’est le meilleur moyen de passer votre soirée à restaurer.
  • Accès à WP-CLI si possible (souvent disponible même sur mutualisé).
  • Un éditeur de code et l’accès FTP/SSH pour déposer un mu-plugin.

Sources utiles (à garder sous la main) :

L’approche naïve (et pourquoi l’éviter)

Le snippet que je vois encore passer (et qui “marche” jusqu’au jour où il ne marche plus) :

<?php
// ❌ Exemple à éviter : script accessible via le web + chargement WordPress à la main
require __DIR__ . '/wp-load.php';

$posts = get_posts(['numberposts' => 10]);
foreach ($posts as $p) {
    echo esc_html($p->post_title) . "<br>";
}

Ce qui cloche, en pratique :

  • Sécurité : si ce fichier est accessible publiquement, vous exposez une surface d’attaque. Beaucoup oublient d’ajouter une authentification.
  • Performance : charger WordPress complet pour une action simple peut coûter cher, surtout si des plugins lourds s’initialisent.
  • Double bootstrap : si vous collez ce code dans un contexte où WP est déjà chargé (ex : functions.php), vous déclenchez des erreurs du type “Cannot redeclare…” ou des comportements bizarres.
  • Maintenance : ce fichier “à côté” n’est pas versionné dans vos pratiques habituelles, et il finit oublié.

La bonne approche — tutoriel pas à pas

On va mettre en place un “kit” de maintenance propre, sous forme de mu-plugin (chargé automatiquement), qui propose :

  • un endpoint REST authentifié pour déclencher une action,
  • un déclenchement asynchrone via WP-Cron pour éviter les timeouts,
  • un mode “dry-run” pour tester sans modifier la base,
  • une commande WP-CLI (optionnelle) si WP-CLI est disponible.

Étape 1 — Créez un mu-plugin

Créez le dossier (s’il n’existe pas) wp-content/mu-plugins/, puis ajoutez un fichier : wp-content/mu-plugins/bpcab-maintenance-kit.php.

Pourquoi un mu-plugin ? Parce qu’il est chargé avant les plugins classiques, ne dépend pas du thème, et évite l’anti-pattern “on met tout dans functions.php”.

Étape 2 — Définissez une action de maintenance réelle

Exemple concret : recalculer un meta reading_time sur les derniers articles (utile pour un thème, un builder, ou un bloc qui affiche “X min de lecture”). J’ai souvent vu ce champ calculé à l’affichage, ce qui coûte une requête + un calcul à chaque page vue.

On va donc :

  • cibler les posts post publiés,
  • calculer un temps de lecture basé sur le nombre de mots,
  • écrire le résultat en post meta,
  • limiter le batch (pagination) pour ne pas exploser la mémoire.

Étape 3 — Exposez un endpoint REST sécurisé

On crée une route /wp-json/bpcab/v1/maintenance/reading-time qui :

  • requiert un utilisateur capable (manage_options),
  • requiert un nonce REST (pour appels depuis l’admin) ou un token HMAC (pour appels serveurs),
  • déclenche un job WP-Cron en arrière-plan.

Étape 4 — Ajoutez WP-Cron pour le traitement asynchrone

Le endpoint doit répondre vite. Le traitement (batch) se fait via un hook cron, relancé jusqu’à terminer.

Étape 5 — (Optionnel) Ajoutez une commande WP-CLI

Pour les imports/maintenances, WP-CLI reste le meilleur outil. Vous évitez les timeouts HTTP, vous avez des logs, et vous pouvez exécuter en SSH.

Code complet

Copiez-collez ce fichier tel quel dans wp-content/mu-plugins/bpcab-maintenance-kit.php.

<?php
/**
 * Plugin Name: BPCAB Maintenance Kit (MU)
 * Description: Endpoint REST + WP-Cron (+ option WP-CLI) pour exécuter des tâches de maintenance propres (WP 6.9.4+, PHP 8.1+).
 * Version: 1.0.0
 * Author: BPCAB
 *
 * Ce fichier doit être placé dans wp-content/mu-plugins/
 */

declare(strict_types=1);

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

final class BPCAB_Maintenance_Kit {

	private const REST_NAMESPACE = 'bpcab/v1';
	private const CRON_HOOK_READING_TIME = 'bpcab_mk_cron_reading_time';
	private const OPTION_SECRET = 'bpcab_mk_secret';
	private const DEFAULT_BATCH_SIZE = 50;

	public static function init(): void {
		add_action('rest_api_init', [__CLASS__, 'register_routes']);
		add_action(self::CRON_HOOK_READING_TIME, [__CLASS__, 'cron_run_reading_time'], 10, 1);

		// Initialisation du secret au besoin (une seule fois).
		add_action('admin_init', [__CLASS__, 'maybe_generate_secret']);

		// Optionnel : commande WP-CLI si WP_CLI est présent.
		if (defined('WP_CLI') && WP_CLI) {
			self::register_wp_cli();
		}
	}

	/**
	 * Génère un secret stocké en option, utilisé pour signer des requêtes serveur-à-serveur.
	 * On le fait côté admin_init pour éviter de créer des options sur le front.
	 */
	public static function maybe_generate_secret(): void {
		if (!current_user_can('manage_options')) {
			return;
		}

		$secret = get_option(self::OPTION_SECRET);
		if (is_string($secret) && strlen($secret) >= 32) {
			return;
		}

		// wp_generate_password() est adapté pour un secret long.
		$secret = wp_generate_password(64, true, true);
		update_option(self::OPTION_SECRET, $secret, true);
	}

	public static function register_routes(): void {
		register_rest_route(
			self::REST_NAMESPACE,
			'/maintenance/reading-time',
			[
				[
					'methods'             => 'POST',
					'callback'            => [__CLASS__, 'rest_trigger_reading_time'],
					'permission_callback' => [__CLASS__, 'rest_permission_callback'],
					'args'                => [
						'batch_size' => [
							'type'              => 'integer',
							'required'          => false,
							'default'           => self::DEFAULT_BATCH_SIZE,
							'sanitize_callback' => 'absint',
						],
						'dry_run' => [
							'type'              => 'boolean',
							'required'          => false,
							'default'           => false,
							'sanitize_callback' => [__CLASS__, 'sanitize_boolean'],
						],
						'cursor' => [
							'type'              => 'integer',
							'required'          => false,
							'default'           => 0,
							'sanitize_callback' => 'absint',
						],
					],
				],
			]
		);
	}

	/**
	 * Permission REST :
	 * - Cas 1 : appel depuis un utilisateur connecté (admin) => capability manage_options + nonce REST standard.
	 * - Cas 2 : appel serveur-à-serveur => HMAC dans en-tête X-BPCAB-Signature + X-BPCAB-Timestamp.
	 *
	 * Note : en REST, WordPress gère le nonce via l'en-tête X-WP-Nonce pour les utilisateurs connectés.
	 */
	public static function rest_permission_callback(WP_REST_Request $request): bool|WP_Error {
		// Utilisateur connecté : on exige une capability forte.
		if (is_user_logged_in()) {
			if (!current_user_can('manage_options')) {
				return new WP_Error('forbidden', 'Droits insuffisants.', ['status' => 403]);
			}
			return true;
		}

		// Sinon : signature HMAC.
		$timestamp = $request->get_header('x-bpcab-timestamp');
		$signature = $request->get_header('x-bpcab-signature');

		if (!is_string($timestamp) || !is_string($signature) || $timestamp === '' || $signature === '') {
			return new WP_Error('unauthorized', 'Signature manquante.', ['status' => 401]);
		}

		if (!ctype_digit($timestamp)) {
			return new WP_Error('unauthorized', 'Timestamp invalide.', ['status' => 401]);
		}

		$ts = (int) $timestamp;

		// Fenêtre anti-rejeu : 5 minutes.
		if (abs(time() - $ts) > 300) {
			return new WP_Error('unauthorized', 'Timestamp expiré.', ['status' => 401]);
		}

		$secret = get_option(self::OPTION_SECRET);
		if (!is_string($secret) || strlen($secret) < 32) {
			return new WP_Error('server_error', 'Secret non initialisé.', ['status' => 500]);
		}

		// Message signé : méthode + route + timestamp + body brut.
		$raw_body = $request->get_body();
		if (!is_string($raw_body)) {
			$raw_body = '';
		}

		$message = implode('|', [
			'POST',
			'/wp-json/' . self::REST_NAMESPACE . '/maintenance/reading-time',
			(string) $ts,
			$raw_body,
		]);

		$expected = hash_hmac('sha256', $message, $secret);

		// Comparaison en temps constant.
		if (!hash_equals($expected, $signature)) {
			return new WP_Error('unauthorized', 'Signature invalide.', ['status' => 401]);
		}

		return true;
	}

	public static function rest_trigger_reading_time(WP_REST_Request $request): WP_REST_Response|WP_Error {
		$batch_size = max(1, min(200, (int) $request->get_param('batch_size')));
		$dry_run    = (bool) $request->get_param('dry_run');
		$cursor     = max(0, (int) $request->get_param('cursor'));

		$payload = [
			'batch_size' => $batch_size,
			'dry_run'    => $dry_run,
			'cursor'     => $cursor,
		];

		// Planifie immédiatement un event unique.
		// On évite wp_schedule_event (récurrent) : ici on veut un job ponctuel, relancé si besoin.
		wp_schedule_single_event(time() + 1, self::CRON_HOOK_READING_TIME, [$payload]);

		return new WP_REST_Response(
			[
				'status'     => 'scheduled',
				'payload'    => $payload,
				'next_step'  => 'WP-Cron va traiter le batch et replanifier si nécessaire.',
				'tip'        => 'Si WP-Cron est désactivé, exécutez wp cron event run --due-now via WP-CLI.',
			],
			202
		);
	}

	/**
	 * Traitement cron : calcule reading_time pour un batch, puis replanifie si on n'a pas fini.
	 *
	 * @param array $payload
	 */
	public static function cron_run_reading_time(array $payload): void {
		$batch_size = isset($payload['batch_size']) ? (int) $payload['batch_size'] : self::DEFAULT_BATCH_SIZE;
		$batch_size = max(1, min(200, $batch_size));

		$dry_run = !empty($payload['dry_run']);
		$cursor  = isset($payload['cursor']) ? (int) $payload['cursor'] : 0;
		$cursor  = max(0, $cursor);

		$query = new WP_Query([
			'post_type'              => 'post',
			'post_status'            => 'publish',
			'posts_per_page'         => $batch_size,
			'orderby'                => 'ID',
			'order'                  => 'ASC',
			'fields'                 => 'ids',
			'no_found_rows'          => true,
			'ignore_sticky_posts'    => true,
			'update_post_meta_cache' => false,
			'update_post_term_cache' => false,
			'paged'                  => 1,
			// Curseur simple : on reprend après un ID donné.
			'date_query'             => [],
			'meta_query'             => [],
			'tax_query'              => [],
			'author__in'             => [],
			'post__not_in'           => [],
			'suppress_filters'       => false,
		]);

		// Filtrer par ID > cursor sans requête custom : on utilise un filtre posts_where.
		// On l'ajoute juste le temps de CETTE requête.
		$filter = static function (string $where) use ($cursor): string {
			global $wpdb;
			if ($cursor > 0) {
				$where .= $wpdb->prepare(" AND {$wpdb->posts}.ID > %d", $cursor);
			}
			return $where;
		};

		add_filter('posts_where', $filter, 10, 1);
		$query->get_posts();
		remove_filter('posts_where', $filter, 10);

		$post_ids = $query->posts;
		if (!is_array($post_ids) || empty($post_ids)) {
			// Rien à faire : fin du job.
			return;
		}

		$last_id = $cursor;

		foreach ($post_ids as $post_id) {
			$post_id = (int) $post_id;
			$last_id = max($last_id, $post_id);

			$content = get_post_field('post_content', $post_id);
			if (!is_string($content)) {
				$content = '';
			}

			// Nettoyage : on retire les shortcodes et balises pour compter des mots “réels”.
			$text = wp_strip_all_tags(strip_shortcodes($content));
			$text = trim(preg_replace('/s+/u', ' ', $text) ?? '');

			$word_count = 0;
			if ($text !== '') {
				// Compte approximatif : suffisant pour un reading time.
				$words = preg_split('/s+/u', $text);
				$word_count = is_array($words) ? count($words) : 0;
			}

			// Hypothèse : 200 mots/minute (ajustez selon votre langue/ton).
			$minutes = (int) max(1, (int) ceil($word_count / 200));

			if (!$dry_run) {
				update_post_meta($post_id, 'reading_time', $minutes);
				update_post_meta($post_id, 'reading_time_words', $word_count);
			}
		}

		// Replanifie le batch suivant.
		$next_payload = [
			'batch_size' => $batch_size,
			'dry_run'    => $dry_run,
			'cursor'     => $last_id,
		];

		wp_schedule_single_event(time() + 2, self::CRON_HOOK_READING_TIME, [$next_payload]);
	}

	public static function sanitize_boolean(mixed $value): bool {
		// Accepte true/false, "true"/"false", 1/0, "1"/"0".
		if (is_bool($value)) {
			return $value;
		}
		if (is_numeric($value)) {
			return ((int) $value) === 1;
		}
		if (is_string($value)) {
			$v = strtolower(trim($value));
			return in_array($v, ['1', 'true', 'yes', 'on'], true);
		}
		return false;
	}

	private static function register_wp_cli(): void {
		WP_CLI::add_command('bpcab reading-time', function(array $args, array $assoc_args) {
			$batch_size = isset($assoc_args['batch_size']) ? (int) $assoc_args['batch_size'] : self::DEFAULT_BATCH_SIZE;
			$batch_size = max(1, min(500, $batch_size));

			$dry_run = !empty($assoc_args['dry-run']);

			$cursor = 0;
			$total  = 0;

			WP_CLI::log('Calcul reading_time (batch=' . $batch_size . ', dry-run=' . ($dry_run ? 'oui' : 'non') . ')');

			while (true) {
				$payload = [
					'batch_size' => $batch_size,
					'dry_run'    => $dry_run,
					'cursor'     => $cursor,
				];

				// On exécute la même logique que le cron, mais en synchrone.
				$before = $cursor;
				self::cron_run_reading_time($payload);

				// On doit deviner si ça a avancé : on relit le dernier ID du batch en refaisant une requête légère.
				$ids = self::get_post_ids_after_cursor($cursor, $batch_size);
				if (empty($ids)) {
					break;
				}
				$cursor = max($ids);
				if ($cursor === $before) {
					break;
				}

				$total += count($ids);
				WP_CLI::log('... traité ~' . $total . ' posts (cursor=' . $cursor . ')');
			}

			WP_CLI::success('Terminé.');
		});
	}

	/**
	 * Helper WP-CLI : récupère des IDs après un curseur sans recalcul.
	 */
	private static function get_post_ids_after_cursor(int $cursor, int $limit): array {
		$q = new WP_Query([
			'post_type'              => 'post',
			'post_status'            => 'publish',
			'posts_per_page'         => $limit,
			'orderby'                => 'ID',
			'order'                  => 'ASC',
			'fields'                 => 'ids',
			'no_found_rows'          => true,
			'update_post_meta_cache' => false,
			'update_post_term_cache' => false,
		]);

		$filter = static function (string $where) use ($cursor): string {
			global $wpdb;
			if ($cursor > 0) {
				$where .= $wpdb->prepare(" AND {$wpdb->posts}.ID > %d", $cursor);
			}
			return $where;
		};

		add_filter('posts_where', $filter, 10, 1);
		$q->get_posts();
		remove_filter('posts_where', $filter, 10);

		return is_array($q->posts) ? array_map('intval', $q->posts) : [];
	}
}

BPCAB_Maintenance_Kit::init();

Explication du code

Ce que fait ce mu-plugin, en clair

  • Il ajoute une route REST qui planifie un job.
  • Le job calcule reading_time par lots, stocke le résultat en meta, puis se replanifie tant qu’il reste des posts.
  • Il évite les timeouts HTTP en déplaçant le “gros” du travail dans WP-Cron.
  • Il propose une signature HMAC pour déclencher le job sans session WordPress (cas serveur-à-serveur).

Pourquoi REST + Cron (au lieu d’un script PHP direct)

Voici ce qui se passe en coulisses sur beaucoup de sites : un script direct est appelé via le web, il charge WordPress, mais il se fait couper (timeout, memory_limit) à mi-chemin. Vous avez alors un état partiellement modifié, et parfois aucune trace exploitable.

REST + Cron découpe le travail. Le endpoint répond vite (202), et le traitement se fait en batch. C’est plus robuste, surtout avec des plugins lourds ou des builders.

Sécurité : capabilities, nonce, et HMAC

  • Utilisateur connecté : on exige manage_options. Pour un appel depuis l’admin, WordPress gère le nonce REST via l’en-tête X-WP-Nonce (classique côté JS admin).
  • Serveur-à-serveur : on utilise une signature HMAC (hash_hmac) sur méthode + route + timestamp + body. La fenêtre de 5 minutes limite le rejeu.

Pourquoi ne pas utiliser une simple “clé secrète dans l’URL” ? Parce que les URLs finissent dans des logs, des analytics, des référents. J’ai déjà vu des clés exposées comme ça sur des sites pourtant bien tenus.

Performance : WP_Query optimisée

  • 'fields' => 'ids' évite de charger des objets post complets.
  • 'no_found_rows' => true évite le SQL de pagination.
  • update_post_meta_cache et update_post_term_cache à false réduisent la mémoire.
  • Batch de 50 par défaut, plafonné à 200 en REST (500 en CLI).

Pourquoi un “curseur” basé sur l’ID

On reprend après le dernier ID traité. C’est simple, stable, et évite les offsets SQL (qui deviennent coûteux sur de gros volumes). Le filtre posts_where ajoute la condition ID > cursor juste pour la requête.

Oui, c’est un filtre. Et oui, il faut le retirer juste après, sinon vous polluez d’autres requêtes. C’est une erreur fréquente quand on débute avec les filtres SQL.

Variantes et cas d’usage

Variante 1 — Déclenchement depuis un script externe (curl) avec HMAC

Si vous avez un serveur d’intégration, un cron système, ou un outil de déploiement, vous pouvez déclencher le endpoint sans session WP.

Exemple bash (vous devez récupérer le secret stocké dans l’option bpcab_mk_secret via WP-CLI une fois) :

# Récupérer le secret (à faire en admin / SSH)
wp option get bpcab_mk_secret

# Déclencher le job (exemple)
URL="https://example.com/wp-json/bpcab/v1/maintenance/reading-time"
TS="$(date +%s)"
BODY='{"batch_size":80,"dry_run":false,"cursor":0}'

# Message signé : méthode|route|timestamp|body
MSG="POST|/wp-json/bpcab/v1/maintenance/reading-time|${TS}|${BODY}"

# Signature HMAC SHA256 (nécessite openssl)
SECRET="COLLEZ_ICI_VOTRE_SECRET"
SIG="$(printf "%s" "$MSG" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"

curl -sS -X POST "$URL" 
  -H "Content-Type: application/json" 
  -H "X-BPCAB-Timestamp: $TS" 
  -H "X-BPCAB-Signature: $SIG" 
  --data "$BODY"

Variante 2 — Calcul à la publication (au lieu d’un job global)

Si votre besoin est “toujours à jour” et que vous publiez peu, calculez au moment où l’article est sauvegardé. C’est souvent plus efficace qu’un batch.

<?php
// À mettre dans un plugin classique ou mu-plugin.
add_action('save_post_post', function(int $post_id, WP_Post $post, bool $update) {
	// ✅ Sécurité : éviter autosave/révisions.
	if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
		return;
	}

	// ✅ Permissions : vérifier l'édition.
	if (!current_user_can('edit_post', $post_id)) {
		return;
	}

	$content = (string) $post->post_content;
	$text = wp_strip_all_tags(strip_shortcodes($content));
	$text = trim(preg_replace('/s+/u', ' ', $text) ?? '');

	$words = $text !== '' ? preg_split('/s+/u', $text) : [];
	$count = is_array($words) ? count($words) : 0;
	$minutes = (int) max(1, (int) ceil($count / 200));

	update_post_meta($post_id, 'reading_time', $minutes);
	update_post_meta($post_id, 'reading_time_words', $count);
}, 10, 3);

Variante 3 — Traiter un Custom Post Type + champ ACF/Meta custom

Vous pouvez adapter la requête à post_type => 'portfolio' ou un CPT Avada/Divi. Le principe reste identique : batch + curseur + meta.

Compatibilité Divi 5 / Elementor / Avada

Le code ci-dessus n’est pas “lié” à un builder, et c’est voulu. Les builders changent, les API internes bougent, mais REST/Cron restent stables.

Divi 5

  • Si vous voulez un bouton “Recalculer reading time” dans l’admin, créez une page d’outil (menu) qui appelle le endpoint REST en JS avec X-WP-Nonce.
  • Divi 5 peut afficher le meta via un module dynamique (selon votre setup). Le point clé est que la meta existe déjà, donc pas de calcul au rendu.

Elementor

  • Elementor consomme très bien les champs meta (dynamic tags). Une fois reading_time stocké, vous l’affichez sans code.
  • Si vous aviez un widget custom qui calculait le reading time à l’affichage, migrez-le pour lire get_post_meta(get_the_ID(), 'reading_time', true) (avec fallback).

Avada (Fusion Builder)

  • Avada a ses propres éléments dynamiques. Même logique : l’intérêt est de déplacer le calcul côté maintenance, pas côté front.
  • Pour déclencher la maintenance, évitez un shortcode public. Préférez un outil admin ou WP-CLI.

Vérifications après mise en place

  • Vérifiez que le mu-plugin est bien chargé : dans l’admin, allez dans “Extensions” → “Must-Use”. Vous devez voir votre plugin.
  • Test REST (connecté admin) : utilisez un client REST (ou l’inspecteur réseau) et appelez /wp-json/bpcab/v1/maintenance/reading-time en POST.
  • Vérifiez que le cron tourne :
    • avec WP-CLI : wp cron event list | grep bpcab_mk_cron_reading_time
    • puis wp cron event run --due-now
  • Contrôlez le résultat sur un post : meta reading_time et reading_time_words présents (via un plugin de debug ou WP-CLI : wp post meta get 123 reading_time).

Tableau de diagnostic rapide

Symptôme Cause probable Vérification Solution
Le endpoint renvoie 401 Signature HMAC absente/invalide Vérifier en-têtes X-BPCAB-* et le message signé Recalculer la signature, vérifier le secret et le chemin exact signé
Le endpoint renvoie 403 Utilisateur connecté sans capability Tester avec un admin Utiliser un compte admin ou ajuster la capability (avec prudence)
“Scheduled” mais rien ne se passe WP-Cron désactivé ou trafic insuffisant DISABLE_WP_CRON dans wp-config, vérifier la liste des events Lancer via WP-CLI ou configurer un cron système
Timeout / 504 Traitement fait en HTTP au lieu de cron Logs serveur, durée de réponse REST Conserver la logique en cron (batch), réduire batch_size
Meta non mise à jour dry_run activé Vérifier le payload Relancer avec dry_run=false

Si ça ne marche pas

  1. Confirmez l’emplacement du fichier : il doit être dans wp-content/mu-plugins/, pas dans plugins/. C’est l’erreur n°1 quand on copie-colle.
  2. Activez WP_DEBUG en staging et regardez wp-content/debug.log. Une parenthèse manquante ou un point-virgule oublié casse tout le site (et ça arrive vite en copiant un snippet).
  3. Testez WP-Cron : si votre site a peu de trafic, WP-Cron ne se déclenche pas “tout seul”. Lancez wp cron event run --due-now.
  4. Désactivez temporairement le cache (plugin + cache serveur) si vous testez via une page admin/JS. J’ai déjà vu un endpoint sembler “bloqué” alors que c’était juste une réponse mise en cache côté reverse-proxy.
  5. Vérifiez PHP : si vous êtes en PHP 7.4/8.0, ce code peut casser (types stricts, signatures). Passez en PHP 8.1+.
  6. Conflits : si un plugin modifie globalement posts_where ou les requêtes, testez en désactivant les plugins de filtrage (SEO avancé, recherche, etc.).

Pièges et erreurs courantes

Erreur Cause Solution
Copier le code dans functions.php du thème parent Mise à jour du thème = code écrasé Utiliser un mu-plugin ou un plugin dédié
Fatal error: Cannot redeclare ... WordPress (ou une classe) chargée deux fois Ne jamais inclure wp-load.php depuis un contexte WP déjà chargé
Le job ne tourne jamais WP-Cron désactivé (DISABLE_WP_CRON) Mettre un cron système ou exécuter les events via WP-CLI
rest_forbidden / 403 Capability trop faible ou utilisateur non admin Tester avec un admin, ou ajuster la permission_callback
401 Signature invalide Message signé différent (route, body, timestamp) Signer exactement méthode|route|timestamp|body brut
Résultats incohérents Shortcodes/constructeurs stockent du contenu “non textuel” Adapter le nettoyage (exclure certains shortcodes, ou compter autrement)
Le CSS/JS “admin” ne charge pas Mauvais hook d’enqueue (ou pas d’enqueue) Enqueue sur admin_enqueue_scripts et vérifier dépendances
Tester directement en production Pas de staging, pas de sauvegarde Staging + snapshot + dry-run
Code d’un ancien tutoriel incompatible API/bonnes pratiques obsolètes Vérifier la doc officielle WP 6.9+ et adapter (REST/Cron/CLI)

Conseils sécurité, performance et maintenance

  • Ne rendez pas l’endpoint public sans auth. Un endpoint qui déclenche un batch d’écriture en base est une cible parfaite pour un DoS applicatif.
  • Limitez les batchs et imposez des plafonds (comme dans le code). Sans ça, quelqu’un mettra batch_size=5000 “pour aller plus vite” et vous exploserez la RAM.
  • Logguez côté serveur si vous industrialisez : vous pouvez ajouter error_log() (en staging) ou un logger PSR-3 via un plugin, mais évitez de logguer des secrets.
  • Préférez WP-CLI pour les opérations lourdes. REST + Cron est bien pour déclencher, mais WP-CLI est meilleur pour exécuter.
  • Cache : après un recalcul de meta utilisée en front, purge du cache page (plugin/CDN) si nécessaire. Beaucoup pensent que “ça n’a pas marché” alors que c’est juste un cache HTML.
  • SEO : stocker reading_time en meta est neutre SEO, mais évite des calculs au rendu et stabilise le TTFB.

Ressources

FAQ

“Utiliser WordPress dans WordPress”, ça veut dire quoi exactement ?

En pratique, ça veut dire exécuter des API WordPress (requêtes, CRUD, hooks, REST, cron) depuis un contexte atypique : script externe, tâche planifiée, bouton d’outil, intégration. Le piège est de croire que “charger WordPress” est la solution. Souvent, WordPress est déjà chargé et vous devez juste choisir le bon hook.

Pourquoi ne pas simplement inclure wp-load.php ?

Parce que c’est fragile et souvent dangereux si le fichier est exposé. Et surtout, c’est inutile dans 80% des cas (vous êtes déjà dans WordPress). Quand vous en avez vraiment besoin, faites-le côté CLI, pas via un endpoint public.

WP-Cron est-il fiable ?

WP-Cron dépend du trafic. Sur un site à faible trafic, il peut “prendre du retard”. Pour des tâches critiques, utilisez un cron système qui appelle wp-cron.php ou exécutez les events via WP-CLI.

Pourquoi stocker le résultat en post meta au lieu de calculer à l’affichage ?

Parce que le front doit rester rapide. Calculer à l’affichage multiplie le coût par le nombre de pages vues. Stocker en meta paie le coût une fois (ou à chaque mise à jour), ce qui est généralement le bon compromis.

Ce code risque-t-il de casser avec Divi/Elementor/Avada ?

Non, il est indépendant. Le seul point à surveiller est le contenu : certains builders stockent des structures (JSON/shortcodes) qui rendent le comptage de mots approximatif. Si votre rendu est très “builder”, adaptez la logique de nettoyage.

Comment déclencher le endpoint REST depuis l’admin avec un nonce ?

Depuis JS admin, vous utilisez wpApiSettings.nonce (ou une localisation de script) et vous envoyez l’en-tête X-WP-Nonce. C’est le flux standard de l’API REST côté WordPress.

Peut-on remplacer HMAC par une Application Password ?

Oui, pour un usage serveur-à-serveur, les Application Passwords peuvent être plus simples. L’HMAC a l’avantage d’éviter l’auth Basic et de réduire l’exposition d’un secret “réutilisable” tel quel. Si vous choisissez les Application Passwords, limitez l’utilisateur et ses capacités.

Pourquoi le code utilise posts_where au lieu d’un paramètre post__in ?

Parce qu’on veut un curseur “ID > X” sans charger une liste d’IDs au préalable. Un offset ou un post__in peut devenir coûteux sur de gros volumes.

Que faire si le job tourne en boucle ?

Ça arrive si votre curseur n’avance pas (requête filtrée, plugin qui modifie l’ordre, ou bug). Vérifiez l’ordre (orderby ID ASC), désactivez temporairement les plugins qui filtrent les requêtes, et logguez le dernier ID traité.

Où récupérer le secret HMAC ?

Une fois connecté en admin (ou via WP-CLI), l’option bpcab_mk_secret est créée. Vous pouvez l’obtenir avec wp option get bpcab_mk_secret. Ne l’affichez jamais publiquement.