real con Yoast 28.5, Rank Math 1.0.279 y * AIOSEO 5.0.1.1. * 4. Si no hay ningún plugin SEO, el propio conector imprime title/description/Open Graph/Twitter * y el JSON-LD (ailoquence_seo_print_head). Con un plugin SEO activo el JSON-LD NO se imprime: * todos generan ya su schema del artículo, y un segundo schema con datos distintos confunde. * * Semántica de parche: solo se tocan los campos presentes en el payload (el backend quita los * vacíos/null), igual que ya hacía el plugin con el resto de metas. */ /** Campo neutro → meta key propia. */ function ailoquence_seo_meta_keys() { return [ 'title' => '_ailoquence_meta_title', 'description' => '_ailoquence_meta_description', 'keywords' => '_ailoquence_keywords', 'og_title' => '_ailoquence_og_title', 'og_description' => '_ailoquence_og_description', 'og_image' => '_ailoquence_og_image', 'twitter_title' => '_ailoquence_twitter_title', 'twitter_description' => '_ailoquence_twitter_description', 'twitter_image' => '_ailoquence_twitter_image', 'schema_json_ld' => '_ailoquence_schema_json_ld', ]; } /** * Payload "meta" → campos neutros. Acepta las claves de AIOSEO (las que envía el backend hoy), * las propias _ailoquence_* y las _aio_* de 0.1.x. 'keywords' queda como lista, la primera es la * palabra clave principal. */ function ailoquence_seo_from_payload(array $meta) { $aliases = [ 'title' => ['_aioseo_title', '_ailoquence_meta_title', '_aio_meta_title'], 'description' => ['_aioseo_description', '_ailoquence_meta_description', '_aio_meta_description'], 'og_title' => ['_aioseo_og_title', '_ailoquence_og_title'], 'og_description' => ['_aioseo_og_description', '_ailoquence_og_description'], 'og_image' => ['_aioseo_og_image_url', '_ailoquence_og_image'], 'twitter_title' => ['_aioseo_twitter_title', '_ailoquence_twitter_title'], 'twitter_description' => ['_aioseo_twitter_description', '_ailoquence_twitter_description'], 'twitter_image' => ['_aioseo_twitter_image_url', '_ailoquence_twitter_image'], 'schema_json_ld' => ['_aioseo_schema_json_ld', '_ailoquence_schema_json_ld', '_aio_schema_json_ld'], ]; $seo = []; foreach ($aliases as $field => $keys) { foreach ($keys as $key) { if (!isset($meta[$key]) || !is_scalar($meta[$key])) continue; $value = trim((string) $meta[$key]); if ($value === '') continue; $seo[$field] = $value; break; } } foreach (['og_image', 'twitter_image'] as $field) { if (isset($seo[$field])) { $seo[$field] = esc_url_raw($seo[$field]); if ($seo[$field] === '') unset($seo[$field]); } } foreach (['title', 'description', 'og_title', 'og_description', 'twitter_title', 'twitter_description'] as $field) { if (isset($seo[$field])) $seo[$field] = sanitize_text_field($seo[$field]); } // El backend serializa el schema con Jackson aunque sea null → llega "null" o "{}". if (isset($seo['schema_json_ld'])) { $decoded = json_decode($seo['schema_json_ld'], true); if (!is_array($decoded) || empty($decoded)) unset($seo['schema_json_ld']); } $keywords = ailoquence_seo_keywords_from_payload($meta); if (!empty($keywords)) $seo['keywords'] = $keywords; return $seo; } /** * _aioseo_keyphrases llega como JSON {"focus":{"keyphrase":..},"extras":[{"keyphrase":..}]} * (formato del backend; AIOSEO 5 llama "additional" a los secundarios, se aceptan ambos). Las * claves propias/antiguas llegan como texto separado por comas. */ function ailoquence_seo_keywords_from_payload(array $meta) { $keywords = []; if (!empty($meta['_aioseo_keyphrases'])) { $kp = is_array($meta['_aioseo_keyphrases']) ? $meta['_aioseo_keyphrases'] : json_decode((string) $meta['_aioseo_keyphrases'], true); if (is_array($kp)) { if (!empty($kp['focus']['keyphrase'])) $keywords[] = $kp['focus']['keyphrase']; foreach (['extras', 'additional'] as $group) { foreach ((array) ($kp[$group] ?? []) as $extra) { if (!empty($extra['keyphrase'])) $keywords[] = $extra['keyphrase']; } } } } else { foreach (['_ailoquence_keywords', '_aio_keywords'] as $key) { if (!empty($meta[$key]) && is_scalar($meta[$key])) { $keywords = explode(',', (string) $meta[$key]); break; } } } $keywords = array_map(function ($k) { return sanitize_text_field((string) $k); }, $keywords); return array_values(array_unique(array_filter($keywords, 'strlen'))); } /** * Plugins SEO activos. 'other' = plugins SEO a los que todavía no sabemos escribir: no se les * escribe nada, pero su presencia basta para que el conector NO imprima sus propias etiquetas * (se duplicarían con las suyas). */ function ailoquence_seo_active_plugins() { $active = []; if (defined('WPSEO_VERSION')) $active[] = 'yoast'; if (class_exists('RankMath')) $active[] = 'rankmath'; if (function_exists('aioseo') && class_exists('\AIOSEO\Plugin\Common\Models\Post')) $active[] = 'aioseo'; if (defined('SEOPRESS_VERSION') || defined('THE_SEO_FRAMEWORK_VERSION') || defined('SLIM_SEO_VER') || defined('SQ_VERSION')) { $active[] = 'other'; } return $active; } /** * Punto de entrada desde /publish. Devuelve la lista de plugins SEO en los que se ha escrito * (se incluye en la respuesta para diagnóstico desde el backend). */ function ailoquence_apply_seo($post_id, array $meta) { $seo = ailoquence_seo_from_payload($meta); if (empty($seo)) return []; foreach (ailoquence_seo_meta_keys() as $field => $meta_key) { if (!isset($seo[$field])) continue; $value = $field === 'keywords' ? implode(', ', $seo[$field]) : $seo[$field]; update_post_meta($post_id, $meta_key, wp_slash($value)); } $written = []; foreach (ailoquence_seo_active_plugins() as $plugin) { if ($plugin === 'yoast') { ailoquence_seo_write_yoast($post_id, $seo); $written[] = 'yoast'; } elseif ($plugin === 'rankmath') { ailoquence_seo_write_rankmath($post_id, $seo); $written[] = 'rankmath'; } elseif ($plugin === 'aioseo' && ailoquence_seo_write_aioseo($post_id, $seo)) { $written[] = 'aioseo'; } } return $written; } function ailoquence_seo_update_meta_map($post_id, array $seo, array $map) { foreach ($map as $field => $meta_key) { if (!isset($seo[$field])) continue; update_post_meta($post_id, $meta_key, wp_slash($seo[$field])); } } /** * ID del adjunto de la biblioteca de medios al que corresponde una URL, o 0. Rank Math solo usa * como imagen social un adjunto (rank_math_*_image_id) — con una URL externa la ignora y cae a la * imagen destacada; Yoast acepta la URL pero prefiere también el ID. En el flujo real la imagen * social suele ser la misma que la destacada, que /publish ya sube a la biblioteca. */ function ailoquence_seo_attachment_id($url) { return $url ? (int) attachment_url_to_postid($url) : 0; } function ailoquence_seo_write_yoast($post_id, array $seo) { ailoquence_seo_update_meta_map($post_id, $seo, [ 'title' => '_yoast_wpseo_title', 'description' => '_yoast_wpseo_metadesc', 'og_title' => '_yoast_wpseo_opengraph-title', 'og_description' => '_yoast_wpseo_opengraph-description', 'og_image' => '_yoast_wpseo_opengraph-image', 'twitter_title' => '_yoast_wpseo_twitter-title', 'twitter_description' => '_yoast_wpseo_twitter-description', 'twitter_image' => '_yoast_wpseo_twitter-image', ]); foreach (['og_image' => '_yoast_wpseo_opengraph-image-id', 'twitter_image' => '_yoast_wpseo_twitter-image-id'] as $field => $meta_key) { $attachment_id = ailoquence_seo_attachment_id($seo[$field] ?? ''); if ($attachment_id) update_post_meta($post_id, $meta_key, $attachment_id); } // Yoast (versión gratuita) solo admite una palabra clave principal. if (!empty($seo['keywords'])) { update_post_meta($post_id, '_yoast_wpseo_focuskw', wp_slash($seo['keywords'][0])); } } function ailoquence_seo_write_rankmath($post_id, array $seo) { ailoquence_seo_update_meta_map($post_id, $seo, [ 'title' => 'rank_math_title', 'description' => 'rank_math_description', 'og_title' => 'rank_math_facebook_title', 'og_description' => 'rank_math_facebook_description', 'og_image' => 'rank_math_facebook_image', 'twitter_title' => 'rank_math_twitter_title', 'twitter_description' => 'rank_math_twitter_description', 'twitter_image' => 'rank_math_twitter_image', ]); foreach (['og_image' => 'rank_math_facebook_image_id', 'twitter_image' => 'rank_math_twitter_image_id'] as $field => $meta_key) { $attachment_id = ailoquence_seo_attachment_id($seo[$field] ?? ''); if ($attachment_id) update_post_meta($post_id, $meta_key, $attachment_id); } // Rank Math guarda todas las palabras clave en un único campo separado por comas; la primera // es la principal. if (!empty($seo['keywords'])) { update_post_meta($post_id, 'rank_math_focus_keyword', wp_slash(implode(',', $seo['keywords']))); } // Sin esto Rank Math ignora los campos de Twitter y reutiliza los de Facebook. if (isset($seo['twitter_title']) || isset($seo['twitter_description']) || isset($seo['twitter_image'])) { update_post_meta($post_id, 'rank_math_twitter_use_facebook', 'off'); } } /** * AIOSEO no lee post meta: hay que pasar por su modelo (el mismo que usa su propio editor). En * try/catch porque es una API interna de AIOSEO que puede cambiar entre versiones — un fallo aquí * nunca debe impedir que el post se publique (el SEO queda igualmente en las metas _ailoquence_*). */ function ailoquence_seo_write_aioseo($post_id, array $seo) { $data = []; foreach (['title', 'description', 'og_title', 'og_description', 'twitter_title', 'twitter_description'] as $field) { if (isset($seo[$field])) $data[$field] = $seo[$field]; } if (isset($seo['og_image'])) { $data['og_image_type'] = 'custom_image'; $data['og_image_custom_url'] = $seo['og_image']; } if (isset($seo['twitter_title']) || isset($seo['twitter_description']) || isset($seo['twitter_image'])) { $data['twitter_use_og'] = false; } if (isset($seo['twitter_image'])) { $data['twitter_image_type'] = 'custom_image'; $data['twitter_image_custom_url'] = $seo['twitter_image']; } if (!empty($seo['keywords'])) { $data['keyphrases'] = [ 'focus' => ['keyphrase' => $seo['keywords'][0], 'score' => 0, 'analysis' => []], 'additional' => array_map(function ($k) { return ['keyphrase' => $k, 'score' => 0, 'analysis' => []]; }, array_slice($seo['keywords'], 1)), ]; } if (empty($data)) return false; try { $result = \AIOSEO\Plugin\Common\Models\Post::savePost($post_id, $data); return empty($result) || $result === true; } catch (\Throwable $e) { return false; } } /* ---------- Salida propia cuando el sitio no tiene plugin SEO ---------- */ /** Metas SEO propias del post actual, o null si no aplica (sin plugin SEO / no es un post). */ function ailoquence_seo_current_post_data() { static $cache = null; if ($cache !== null) return $cache ?: null; $cache = false; if (!is_singular('post') || !empty(ailoquence_seo_active_plugins())) return null; $post_id = get_queried_object_id(); $data = []; foreach (ailoquence_seo_meta_keys() as $field => $meta_key) { $value = (string) get_post_meta($post_id, $meta_key, true); if ($value !== '') $data[$field] = $value; } if (empty($data)) return null; $data['post_id'] = $post_id; $cache = $data; return $data; } add_filter('pre_get_document_title', function ($title) { $data = ailoquence_seo_current_post_data(); return !empty($data['title']) ? $data['title'] : $title; }, 20); add_action('wp_head', 'ailoquence_seo_print_head', 1); function ailoquence_seo_print_head() { $data = ailoquence_seo_current_post_data(); if (!$data) return; $post_id = $data['post_id']; $title = $data['title'] ?? get_the_title($post_id); $description = $data['description'] ?? ''; $og_title = $data['og_title'] ?? $title; $og_desc = $data['og_description'] ?? $description; $og_image = $data['og_image'] ?? (string) get_the_post_thumbnail_url($post_id, 'full'); $tw_title = $data['twitter_title'] ?? $og_title; $tw_desc = $data['twitter_description'] ?? $og_desc; $tw_image = $data['twitter_image'] ?? $og_image; $tags = [ ['name', 'description', $description], ['property', 'og:type', 'article'], ['property', 'og:title', $og_title], ['property', 'og:description', $og_desc], ['property', 'og:url', get_permalink($post_id)], ['property', 'og:image', $og_image], ['name', 'twitter:card', $tw_image !== '' ? 'summary_large_image' : 'summary'], ['name', 'twitter:title', $tw_title], ['name', 'twitter:description', $tw_desc], ['name', 'twitter:image', $tw_image], ]; echo "\n\n"; foreach ($tags as $tag) { list($attr, $name, $content) = $tag; if ((string) $content === '') continue; $content = in_array($name, ['og:url', 'og:image', 'twitter:image'], true) ? esc_url($content) : esc_attr($content); printf('' . "\n", esc_attr($attr), esc_attr($name), $content); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $content escaped just above. } if (!empty($data['schema_json_ld'])) { $schema = json_decode($data['schema_json_ld'], true); if (is_array($schema)) { // JSON_HEX_TAG: un "" dentro de un valor del schema no puede cerrar la etiqueta. wp_print_inline_script_tag( wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG), ['type' => 'application/ld+json'] ); } } }