WordPin analizando un plugin SEO que duplica el marcado Schema en WordPress

El plugin SEO que duplicó los schema · WordPin 014

🎧 Escuchar: El plugin SEO que duplicó los schema · WordPin 014

El sitio llevaba meses funcionando sin incidencias visibles. Un magazine gastronómico con recetas propias, fichas de ingredientes, temporadas y reseñas de restaurantes. El tipo de proyecto donde el marcado estructurado no es un extra: es parte de la arquitectura. Cada receta necesitaba su bloque de Recipe bien formado para tener alguna opción real en los resultados enriquecidos de Google. Y en apariencia, lo tenía. El plugin SEO estaba activo, configurado y generando schema sin errores visibles.

El aviso llegó de forma indirecta. Al revisar el rendimiento orgánico de varias recetas en Google Search Console, algunas páginas que deberían aparecer con rich snippets no los mostraban de forma consistente. El plugin SEO marcaba todo como correcto en su panel. El validador, a primera vista, tampoco devolvía errores críticos. Pero había algo que no cuadraba: en ciertas URLs, los resultados del validador mostraban entidades repetidas del mismo tipo. No eran errores que bloquearan la indexación, pero tampoco era un estado limpio.

Demasiado ordenado para ser un fallo puntual. Demasiado extendido para ser un error de configuración menor.

Sello que marca el archivo 014 de WordPin como abierto
Ficha del archivo Nº: 014

Operario: WordPin

Archivo del caso: El plugin SEO que duplicó los schema

Empresa o negocio: Magazine gastronómico con recetas

Archivado en: Plugins, temas y conflictos

Nivel: Intermedio

Lo que el plugin SEO no estaba viendo desde su panel

El primer paso fue revisar el código fuente directamente, sin intermediarios. No el DOM renderizado en el navegador, sino el HTML crudo que el servidor devolvía. En una receta de temporada, hacia el final del <head>, aparecía algo que no debería estar duplicado:


<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Recipe",
  "name": "Gazpacho andaluz tradicional",
  "recipeIngredient": ["tomate", "pepino", "ajo", "aceite de oliva"],
  "recipeYield": "4 raciones"
}
</script>

<!-- más adelante en el mismo documento -->

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Recipe",
  "name": "Gazpacho andaluz tradicional",
  "recipeIngredient": ["tomate", "pepino", "ajo", "aceite de oliva", "vinagre"],
  "recipeYield": "4 raciones",
  "cookTime": "PT20M"
}
</script>

Dos bloques JSON-LD del mismo tipo, para el mismo contenido, en el mismo documento. Con pequeñas diferencias entre sí: uno más completo que el otro, con campos distintos parcialmente solapados. Esto no era ruido aleatorio. El patrón se repetía en varias recetas revisadas. Y en las páginas que no eran recetas —categorías, páginas estáticas— el problema no aparecía con la misma regularidad.

La primera hipótesis fue la caché. El sitio usaba un plugin de caché de página, y no era descabellado pensar que alguna versión cacheada llevara un bloque schema antiguo que persistía junto a uno nuevo generado dinámicamente. Un error de invalidación de caché en el momento equivocado podía haber dejado ese estado mezclado.

Para descartarlo o confirmarlo, lo más limpio era verificar con Schema Markup Validator usando tanto la URL normal como una segunda petición con un parámetro de consulta añadido, para comprobar si el resultado variaba. El resultado fue idéntico en ambas peticiones: los dos bloques Recipe aparecían en las dos versiones.

La pista que señalaba al plugin SEO y el desvío que siguió

Con la caché sin confirmar como causa, el foco volvió al plugin SEO. Yoast SEO, en su configuración estándar para proyectos con contenido editorial, genera un grafo schema estructurado que incluye WebPage, BreadcrumbList y WebSite. En este caso, el grafo incluía también Recipe: el proyecto usaba WP Recipe Maker como plugin principal de recetas, y su integración documentada con Yoast añade esa entidad al grafo cuando detecta el tipo de contenido correspondiente. La pregunta era si el propio plugin SEO estaba generando dos bloques por algún conflicto interno o configuración incorrecta.

Se activó Query Monitor para rastrear qué callbacks estaban registrados en wp_head y wp_footer durante la carga de una receta. La lista de entradas en wp_head era larga, como siempre. Yoast aparecía con su prioridad habitual. Pero había otras dos entradas registradas en ese mismo hook con prioridades distintas.

Una de ellas llamaba la atención: pertenecía al tema activo. No a un plugin auxiliar, sino a las funciones del propio tema. El tema era un diseño premium orientado a publicaciones de contenido gastronómico, con soporte visual para fichas de recetas integrado de serie. Y ese soporte visual incluía algo más que estilos.

Antes de profundizar en esa dirección, hubo un segundo desvío. El sitio tenía instalado un segundo plugin de recetas, más antiguo, que el equipo había probado antes de optar por WP Recipe Maker y que había quedado activo sin uso aparente. Plugins de recetas como ese pueden generar su propio marcado Recipe de forma autónoma, con campos propios extraídos de sus bloques personalizados de Gutenberg. Si seguía activo, aunque sin uso real, podía estar inyectando schema sin que nadie lo hubiera advertido.

El síntoma encajaba: un bloque con menos campos podía ser el de ese plugin secundario —que solo tenía acceso a los datos que hubieran sido completados en su interfaz—, y el bloque más completo sería el del plugin SEO. Parecía coherente.

Pero al desactivar temporalmente ese segundo plugin de recetas en un entorno de staging y recargar una de las páginas afectadas, los dos bloques JSON-LD seguían apareciendo. El plugin secundario no era la fuente del bloque duplicado. Había algo más.

Diagnóstico: dos sistemas de schema sin saber que el otro existía

La pista real estaba donde Query Monitor la había señalado desde el principio: en las funciones del tema. Al revisar el código del callback registrado por el tema activo, y después en su functions.php, había un bloque de código que nadie en el equipo recordaba haber añadido. Era parte del tema base, incluido por defecto en la versión que habían instalado:


add_action( 'wp_head', 'tema_gastro_schema_recipe', 5 );

function tema_gastro_schema_recipe() {
    if ( is_singular( 'post' ) && has_tag( 'receta' ) ) {
        $post = get_queried_object();
        $schema = array(
            '@context' => 'https://schema.org',
            '@type'    => 'Recipe',
            'name'     => get_the_title( $post ),
            'recipeIngredient' => get_post_meta( $post->ID, '_ingredientes', true ),
            'recipeYield'      => get_post_meta( $post->ID, '_raciones', true ),
        );
        echo '<script type="application/ld+json">' . wp_json_encode( $schema ) . '</script>';
    }
}

El tema tenía su propio sistema de datos estructurados integrado, diseñado para posts etiquetados como "receta". Generaba un bloque Recipe básico con los campos guardados en meta personalizados del tema. Al mismo tiempo, el plugin SEO generaba su propio bloque Recipe a través de la integración con WP Recipe Maker, más completo, con campos adicionales como cookTime, recipeCategory o author, extraídos de los datos del plugin de recetas.

Los dos sistemas trabajaban en paralelo, con prioridades de hook distintas —el tema a prioridad 5, Yoast en su prioridad estándar—, sin ningún mecanismo de coordinación entre ellos. El resultado era exactamente lo que aparecía en el código fuente: dos bloques JSON-LD de tipo Recipe sobre el mismo post, con campos parcialmente solapados y parcialmente distintos.

Eso explicaba también por qué el problema era selectivo. Solo afectaba a los posts etiquetados como "receta" porque era la condición del tema para activar su función. Las páginas estáticas, las categorías o los posts sin esa etiqueta no tenían el segundo bloque. La condición has_tag( 'receta' ) era la frontera exacta del problema.

Un error parcial suele ser más peligroso que una caída total, porque deja demasiadas cosas funcionando.

Los dos bloques describían la misma receta con datos distintos y sin coordinación entre ellos. Esa representación duplicada y no integrada es el tipo de marcado que puede dar lugar a una interpretación conflictiva, y encajaba con la inconsistencia observada en la aparición de resultados enriquecidos para esas recetas.

Eliminar el ruido sin silenciar lo que funcionaba

Había dos caminos evidentes. El primero era desactivar la generación de schema desde el tema, manteniendo el plugin SEO como única fuente de marcado estructurado. El segundo era desactivar el schema de Yoast para el tipo de contenido afectado y dejar que el tema hiciera su trabajo. La diferencia entre ambos no era trivial.

El schema del tema era incompleto. Solo recogía tres o cuatro campos porque dependía de metadatos propios que no siempre estaban rellenos. El del plugin SEO era más robusto, extraía datos de múltiples fuentes y generaba un grafo más coherente con el resto de entidades del documento. Además, tocar la configuración del plugin SEO para excluir un tipo de contenido de forma selectiva requería lógica adicional que podía tener efectos secundarios en otros tipos de post.

La decisión fue eliminar la función del tema. Pero no modificando directamente el functions.php del tema padre, sino anulando el hook desde el tema hijo.

Remover el hook antes de que actuara

El tema hijo ya existía en el proyecto. La corrección fue añadir en su functions.php una llamada a remove_action enganchada a after_setup_theme:


add_action( 'after_setup_theme', function() {
    remove_action( 'wp_head', 'tema_gastro_schema_recipe', 5 );
});

El orden de carga importaba aquí. El functions.php del tema hijo se carga inmediatamente antes que el del tema padre. Para cuando after_setup_theme dispara, ambos functions.php ya se han cargado y el callback tema_gastro_schema_recipe ya está registrado en wp_head. El remove_action actúa en ese momento, antes de que wp_head llegue a ejecutarse durante la generación del documento. Esa ventana —después del registro, antes del disparo— es la que hace que la corrección funcione.

El riesgo de haber modificado el tema padre directamente era claro: cualquier actualización del tema restauraría la función y el problema volvería sin aviso. Con el remove_action en el tema hijo, la corrección sobrevivía a las actualizaciones del tema padre.

Tras aplicar el cambio, se validaron de nuevo las URLs afectadas con el Schema Markup Validator. En todas las recetas comprobadas, el código fuente mostraba un único bloque JSON-LD del grafo de Yoast que incluía la entidad Recipe, con todos sus campos y sin duplicados. El grafo seguía coherente: WebPage, BreadcrumbList y Recipe bien conectados entre sí, sin entidades repetidas ni solapadas.

Se revisaron también las páginas que no eran recetas para confirmar que el remove_action no había afectado a nada fuera del scope esperado. Todo estaba limpio. La configuración del plugin SEO no había necesitado ningún ajuste: el problema nunca había sido suyo.

El sello de WordPin que marca el archivo 014 completado

💡 Qué he aprendido

Cuando el plugin SEO está bien configurado y el validador sigue devolviendo entidades duplicadas, la causa rara vez está en el plugin. Este caso me confirmó que los temas premium orientados a nichos de contenido —gastronomía, viajes, lifestyle— llevan integradas funcionalidades de marcado estructurado que nadie activa conscientemente porque llegan activadas de serie. No aparecen en la documentación principal del tema, no generan conflictos visibles en el panel de WordPress, y no producen errores PHP. Simplemente se ejecutan.

Aprendí también a no descartar las funciones del tema hasta haberlas revisado en Query Monitor. En este caso, la señal estaba ahí desde el primer rastreo de callbacks registrados en el hook, pero la hipótesis de la caché y la del plugin secundario de recetas la taparon durante un rato. Los falsos positivos eran plausibles, y eso es lo más útil que tienen: te obligan a demostrar que algo no es el problema antes de avanzar.

El remove_action desde el tema hijo fue la corrección más sostenible porque no dejó deuda técnica. No siempre la solución correcta es la más rápida. Aquí era la misma.

Cuaderno de WordPin

Forense WordPress

Archivo Nº 014:: El plugin SEO que duplicó los schema

🧩 Notas técnicas

⬋⬋

Un magazine gastronómico con recetas presentaba bloques JSON-LD duplicados en sus páginas de receta. El plugin SEO generaba la entidad Recipe a través de su integración con WP Recipe Maker; el tema activo inyectaba por su cuenta un bloque Recipe propio a través de un hook en wp_head con prioridad 5, condicionado a posts con la etiqueta "receta".

La duplicidad no generaba errores críticos en el validador, pero producía entidades solapadas con datos distintos para el mismo contenido. La corrección fue anular el hook del tema padre desde el tema hijo mediante remove_action, ejecutado después del registro del callback y antes del disparo de wp_head, preservando como única fuente el grafo estructurado del plugin SEO.

🔎 Pistas detectadas

⬋⬋

  • Dos bloques <script type="application/ld+json"> de tipo Recipe en el código fuente de las páginas de receta, con campos parcialmente distintos.
  • El problema solo afectaba a posts etiquetados como "receta", no a otras secciones del sitio.
  • Query Monitor localizó dos callbacks distintos registrados en wp_head con prioridades diferentes; la inspección del código de cada uno reveló cuál generaba el bloque JSON-LD independiente del tema.
  • Las recetas no aparecían de forma consistente como resultados enriquecidos en Google, y los informes de Search Console no mostraban errores explícitos que lo justificaran.
  • El schema del tema usaba metadatos propios menos completos que los que manejaba el plugin SEO, lo que generaba dos representaciones de la misma entidad con información divergente.

🚫 Errores en WordPress detectados

⬋⬋

  • Schema duplicado sin coordinación: el tema y el plugin SEO inyectaban bloques Recipe independientes; ninguno sabía que el otro existía, lo que generaba entidades solapadas para el mismo contenido.
  • Función de schema embebida en el tema padre: la lógica de marcado estructurado estaba en functions.php del tema base, activada por defecto, sin documentación visible ni opción de desactivación en el panel.
  • Condición de activación opaca: el hook del tema solo se disparaba con la etiqueta "receta", lo que hacía el problema selectivo y más difícil de detectar en una revisión general del sitio.
  • Campos solapados con valores distintos: los dos bloques Recipe describían el mismo post pero con datos diferentes, produciendo una representación no coordinada de la misma entidad estructurada.
  • Corrección no protegida contra actualizaciones: modificar el tema padre directamente habría reintroducido el problema en la siguiente actualización del tema; la solución correcta requería intervenir desde el tema hijo.

📘 Glosario de WordPress

⬋⬋

  • JSON-LD: formato de marcado estructurado en JavaScript embebido en el HTML; es el método preferido por Google para interpretar datos schema.
  • wp_head (hook): acción de WordPress que se ejecuta en el <head> del documento y permite a plugins y temas añadir contenido en ese punto.
  • remove_action: función de WordPress que elimina un callback registrado previamente en un hook; deben coincidir el hook, el callback y la prioridad con el registro original.
  • Schema @type Recipe: entidad de schema.org que describe una receta dentro de un grafo JSON-LD; proporciona a los buscadores datos estructurados que pueden utilizar para resultados enriquecidos de recetas.
  • Tema hijo (child theme): tema derivado del principal que hereda su diseño pero permite modificar comportamiento sin alterar el código base, preservando los cambios ante actualizaciones.

📏 Mini guía práctica

⬋⬋

  1. Inspecciona el código fuente de tus páginas clave con Ctrl+U y busca application/ld+json para comprobar si existen varios bloques que describen la misma entidad y revisar si proceden de fuentes distintas.
  2. Usa el Schema Markup Validator con la URL real del post para ver todas las entidades detectadas y comprobar si alguna aparece duplicada.
  3. Activa Query Monitor y revisa los callbacks registrados en wp_head; localiza qué componentes tienen entradas en ese hook e inspecciona el código de cada uno para determinar cuál genera salida JSON-LD independiente.
  4. Si el tema genera su propio schema prescindible frente al del plugin SEO, anúlalo desde el tema hijo con remove_action asegurando que hook, callback y prioridad coinciden con el registro original del tema padre.
  5. Valida de nuevo tras la corrección en varias URLs del mismo tipo de contenido para confirmar que el grafo estructurado queda limpio y sin entidades duplicadas.

Comparte el conocimiento. Comparte la historia

Expedientes y Archivos

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Respetamos tu privacidad. Los datos que proporciones se utilizarán únicamente para gestionar y mostrar tu comentario, así como para prevenir el uso indebido o el spam. Puedes consultar más información en nuestra política de privacidad.

Subir