Feeds RSS de WordPress a fondo: guía para desarrolladores

Feeds RSS de WordPress a fondo: guía para desarrolladores

Última verificación: 12 de octubre de 2026
26 min de lectura
Guía
Desarrollador full-stack

WordPress lleva años sirviendo contenido en XML, desde antes de que alguien inventara el “headless CMS”. Un feed RSS sigue siendo el contrato más sencillo: una lista de elementos con título, contenido o extracto, fecha y enlace canónico. Agregadores, importadores de newsletters, lectores y directorios de podcasts lo siguen consumiendo.

Este texto es para el desarrollador que tiene un tema o un plugin pequeño y quiere controlar los feeds sin instalar un paquete de “RSS para todo”. Cubre los endpoints por defecto, lo que la plantilla RSS2 envía realmente frente a la especificación, add_feed, la caché, la desactivación segura, la enumeración de usuarios a través del feed de autor, los podcasts, JSON Feed y WebSub. Los fragmentos sobre el comportamiento del core están comprobados en el código fuente de WordPress (wp-includes/feed-rss2.php, feed-atom.php, feed.php, class-wp.php).

#Qué feeds RSS tiene WordPress por defecto

El core registra varios tipos de feed. Con los enlaces permanentes “bonitos” activados, lo habitual es ver:

  • /feed/ o /feed/rss2/: RSS 2.0 (el predeterminado)
  • /feed/atom/: Atom
  • /feed/rdf/: RDF/RSS 1.0
  • /comments/feed/: comentarios de todo el sitio
  • /category/slug/feed/, /tag/slug/feed/, /author/login/feed/, /search/frase/feed/: feeds contextuales

Sin enlaces permanentes bonitos, los mismos datos salen por query string (?feed=rss2). La función do_feed() lee la variable feed y dispara la acción do_feed_{nombre}; los manejadores por defecto cargan los archivos wp-includes/feed-*.php. Si no hay ninguna acción para ese nombre, WordPress termina la petición con un error 404.

En producción importan tres cosas:

  1. Discovery: el core imprime <link rel="alternate" type="application/rss+xml" ...> en wp_head mediante las funciones feed_links y feed_links_extra, siempre que el tema declare add_theme_support( 'automatic-feed-links' ). El lector y el crawler toman lo que ven en el HTML, no lo que “debería estar”.
  2. Alcance de las consultas: el feed principal toma las entradas (post). Los CPT, las páginas y los estados personalizados no entran hasta que modificas pre_get_posts.
  3. Contenido frente a extracto: la opción rss_use_excerpt (Ajustes, Lectura) decide la forma de cada elemento. La plantilla RSS2 imprime siempre <description> con el extracto (the_excerpt_rss()) y, si se publica el texto completo, añade <content:encoded> con el contenido de get_the_content_feed(). Los filtros the_content_feed y the_excerpt_rss actúan sobre esos dos campos.

Si estás construyendo un importador de newsletter, revisa primero /feed/ sin plugins. Muchas veces el problema no es “que no haya RSS”, sino que falta la miniatura, faltan los CPT o hay un CDN demasiado agresivo que cachea el XML como si fuera HTML.

Hooks de la capa XML que conviene conocer antes de escribir tu propia plantilla: rss2_ns (namespaces en la raíz), rss2_head (hijos de <channel>), rss2_item (hijos de cada <item>) y sus equivalentes en Atom (atom_ns, atom_head, atom_entry). Los filtros de contenido (the_content_feed, the_excerpt_rss) actúan antes, sobre la cadena HTML que se inserta en el elemento. El orden importa: primero construyes el contenido del elemento y después añades los elementos XML a su alrededor.

El número de elementos del feed principal lo controla la opción posts_per_rss (Ajustes, Lectura): WP_Query la usa como posts_per_page en las consultas de feed, y el mismo límite recorta el feed de comentarios. No la subas a cientos “por si acaso”: un XML pesado con contenido completo y shortcodes dispara el TTFB y revienta los límites de memoria de PHP en un alojamiento compartido.

#Qué envía la plantilla RSS2 de WordPress según la especificación RSS 2.0

La especificación RSS 2.0 exige tres elementos en el canal: title, link y description. Dentro de un elemento todos son opcionales, pero debe aparecer al menos title o description. Las fechas siguen el formato de RFC 822. La plantilla feed-rss2.php cumple ese mínimo y añade algunos elementos que conviene conocer antes de duplicarlos desde hooks:

  • <atom:link rel="self"> con la dirección del propio feed (el namespace de Atom está declarado en la raíz de RSS2),
  • <lastBuildDate> y <language> tomados de los ajustes del sitio,
  • <sy:updatePeriod> y <sy:updateFrequency> del módulo Syndication, por defecto hourly y 1, modificables con los filtros rss_update_period y rss_update_frequency,
  • en cada elemento, <dc:creator>, las categorías, <pubDate> en la zona +0000 y <guid isPermaLink="false">.

El elemento <ttl> (número de minutos durante los que se puede cachear el canal) no aparece en la plantilla del core. Si tu lector o agregador lo respeta, lo añades en rss2_head.

guid merece atención aparte. La especificación lo describe como una cadena que identifica el elemento de forma única; isPermaLink vale true por defecto y, con false, el lector no puede dar por hecho que sea una URL. WordPress imprime el campo guid de la entrada, fijado al crearla (normalmente con la forma https://dominio/?p=123), y lo marca como isPermaLink="false". Es la decisión correcta: cambiar el slug o el dominio no cambia el identificador, así que los lectores no muestran entradas antiguas como nuevas. No sobrescribas guid al migrar de dominio si no quieres que tus suscriptores reciban de nuevo todo el archivo.

La especificación incluye también <enclosure>, con tres atributos obligatorios: url, length (tamaño en bytes) y type (tipo MIME). La plantilla RSS2 llama a rss_enclosure(), que lee el campo personalizado enclosure de la entrada (URL, longitud y tipo en líneas consecutivas) e imprime el elemento ya montado. Antes de añadir tu propio enclosure en rss2_item, comprueba que la entrada no tenga ya ese campo, o acabarás con un duplicado.

#En qué se diferencia Atom de RSS 2.0 en WordPress

Atom está definido en el RFC 4287 de 2005 y es más estricto que RSS. El elemento atom:feed debe contener exactamente un atom:id, un atom:title y un atom:updated, además de al menos un atom:author, salvo que cada entrada tenga su propio autor. Esos mismos tres elementos son obligatorios en cada atom:entry. El feed debería tener un atom:link con rel="self", las fechas deben ajustarse al formato date-time de RFC 3339 y el tipo MIME es application/atom+xml.

La plantilla feed-atom.php lo resuelve así: el <id> del canal es la dirección del feed Atom, el <id> de cada entrada es su guid, cada entrada tiene <author> y, junto a <published>, imprime <updated> con la fecha de la última modificación de la entrada. La diferencia práctica: el RSS2 del core solo tiene pubDate, así que una corrección en un texto ya publicado no deja rastro en él, mientras que Atom la señala con updated. Si tus consumidores sincronizan cambios de contenido (archivos, buscadores internos, importadores), dales /feed/atom/.

#Cómo añadir miniaturas y medios al feed RSS

El feed por defecto no añade la imagen destacada al contenido. La mayoría de las herramientas de newsletter buscan un <img> en el contenido del elemento o en Media RSS. Una corrección mínima y predecible:

declare(strict_types=1);

function wppoland_feed_prepend_thumbnail( string $content ): string {
	if ( ! is_feed() || ! has_post_thumbnail() ) {
		return $content;
	}

	$post_id = get_the_ID();
	$url     = get_the_post_thumbnail_url( $post_id, 'medium_large' );

	if ( ! $url ) {
		return $content;
	}

	$img = sprintf(
		'<p><img src="%s" alt="%s" /></p>',
		esc_url( $url ),
		esc_attr( get_the_title( $post_id ) )
	);

	return $img . $content;
}
add_filter( 'the_content_feed', 'wppoland_feed_prepend_thumbnail' );
add_filter( 'the_excerpt_rss', 'wppoland_feed_prepend_thumbnail' );

Con el texto completo en el feed, ambos filtros afectan al mismo elemento (el extracto en <description>, el contenido en <content:encoded>), así que la imagen aparecerá en los dos campos. La mayoría de los lectores muestran content:encoded, y los importadores de newsletters suelen leer description, por eso conviene mantener los dos filtros.

Para los lectores que respetan Media RSS también puedes añadir el namespace xmlns:media="http://search.yahoo.com/mrss/" en rss2_ns y el elemento media:content en rss2_item:

declare(strict_types=1);

function wppoland_media_ns(): void {
	echo 'xmlns:media="http://search.yahoo.com/mrss/"' . "\n";
}
add_action( 'rss2_ns', 'wppoland_media_ns' );

function wppoland_media_content(): void {
	$thumb_id = get_post_thumbnail_id();
	$src      = $thumb_id ? wp_get_attachment_image_src( $thumb_id, 'large' ) : false;

	if ( ! $src ) {
		return;
	}

	printf(
		'<media:content url="%s" medium="image" type="%s" width="%d" height="%d" />' . "\n",
		esc_url( $src[0] ),
		esc_attr( (string) get_post_mime_type( $thumb_id ) ),
		(int) $src[1],
		(int) $src[2]
	);
}
add_action( 'rss2_item', 'wppoland_media_content' );

No mezcles los dos enfoques sin necesidad: un canal, una convención, menos sorpresas en los importadores.

#Retrasar la publicación en el feed

Scrapers y agregadores descargan /feed/ segundos después de publicar. Si tu original se indexa más despacio que la copia, tienes el clásico conflicto de canonicidad. Retrasar una hora los elementos en la consulta del feed da al crawler de tu propio dominio ventaja temporal:

declare(strict_types=1);

function wppoland_delay_feed_posts( string $where, WP_Query $query ): string {
	global $wpdb;

	if ( ! $query->is_main_query() || ! $query->is_feed() || $query->is_comment_feed() ) {
		return $where;
	}

	$where .= $wpdb->prepare(
		" AND {$wpdb->posts}.post_date_gmt <= %s ",
		gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS )
	);

	return $where;
}
add_filter( 'posts_where', 'wppoland_delay_feed_posts', 10, 2 );

El filtro posts_where recibe el objeto de la consulta como segundo argumento, así que la condición afecta solo a la consulta principal del feed y no a widgets o bloques renderizados en la misma petición. Comparar la columna con una fecha ya calculada (en lugar de aplicar TIMESTAMPDIFF sobre la columna) permite a la base de datos usar el índice de post_date_gmt.

Esto no sustituye a rel=canonical ni al sitemap. Es solo un colchón temporal en la capa XML. En sitios con correcciones frecuentes después de publicar, fija un umbral más largo o excluye los estados de borrador de otra forma.

#Custom post types en el feed principal

El /feed/ principal lee el tipo post. Portfolio, eventos o notas de versión quedan fuera de la lista hasta que amplías la consulta:

declare(strict_types=1);

function wppoland_include_cpt_in_main_feed( WP_Query $query ): void {
	if ( is_admin() || ! $query->is_main_query() || ! $query->is_feed() ) {
		return;
	}

	// Solo el feed principal, no /author/.../feed/ ni los comentarios.
	if ( $query->is_comment_feed() || $query->is_author() || $query->is_category() || $query->is_tag() || $query->is_search() ) {
		return;
	}

	$query->set(
		'post_type',
		array( 'post', 'portfolio', 'event' )
	);
}
add_action( 'pre_get_posts', 'wppoland_include_cpt_in_main_feed' );

La condición is_main_query() es clave: sin ella modificarías cada consulta que se ejecuta mientras se genera el feed, incluidas las de los plugins. Un feed aparte para el CPT (/feed/portfolio/) suele quedar más limpio que mezclar tipos en un mismo XML. Un tipo de contenido registrado con has_archive tiene además su propio feed bajo la dirección del archivo, por ejemplo /portfolio/feed/.

#Cómo crear un feed RSS propio con add_feed

add_feed( $feedname, $callback ) registra la regla de reescritura /feed/{nombre}/ y llama al callback en lugar de la plantilla RSS2 estándar. Es la API oficial descrita en la documentación de feeds y en la referencia de add_feed. El callback recibe dos argumentos: si se trata de un feed de comentarios y el nombre del feed.

Patrón para producción:

declare(strict_types=1);

function wppoland_register_release_feed(): void {
	add_feed(
		'releases',
		static function (): void {
			header( 'Content-Type: application/rss+xml; charset=' . get_option( 'blog_charset' ), true );
			echo '<?xml version="1.0" encoding="' . esc_attr( get_option( 'blog_charset' ) ) . '"?' . '>';
			// Aquí construyes el documento RSS2 completo o cargas tu propia plantilla.
			load_template( get_stylesheet_directory() . '/feed-releases.php' );
		}
	);
}
add_action( 'init', 'wppoland_register_release_feed' );

Tras el primer registro (o tras cambiar el nombre del feed) ejecuta flush_rewrite_rules() una sola vez, al activar el plugin, no en cada init. Sin el flush obtendrás un 404 en /feed/releases/ aunque el código del callback sea correcto.

Dentro del callback:

  • fija Content-Type antes de imprimir un solo byte de XML,
  • escapa cada campo de usuario (esc_xml / esc_url / esc_html),
  • limita la consulta (posts_per_page, estado publish, quizá una meta query),
  • no llames a shortcodes pesados sin caché.

La cabecera dentro del callback hace falta por un motivo concreto. En cada petición de feed, WP::send_headers() fija Content-Type con la función feed_content_type(), que solo conoce rss, rss2, rss-http, atom y rdf. Para un nombre desconocido devuelve application/octet-stream. O sobrescribes la cabecera en el callback (segundo argumento true), o añades el tipo con el filtro feed_content_type, que recibe el tipo MIME y el nombre del feed.

Si solo quieres una variante del RSS2 principal (otro número de elementos, otro tipo), a veces basta con add_feed + pre_get_posts sobre is_feed( 'releases' ) y la plantilla estándar. Un archivo de plantilla propio tiene sentido cuando el XML lleva otros namespaces u otra estructura de elementos.

El nombre del feed acaba en la etiqueta de reescritura feed. Cuidado con los nombres del core (rss2, atom, rdf, rss): add_feed elimina el manejador por defecto do_feed_{nombre} y engancha tu callback, así que add_feed( 'rss2', ... ) sustituye sin avisar el feed principal de todo el sitio. Si cambias el nombre, la URL antigua deja de funcionar hasta que añadas una redirección en la configuración del servidor o en template_redirect. En pruebas locales, después de add_feed entra en Ajustes, Enlaces permanentes y guarda sin cambiar nada si no tienes el flush en la activación del plugin. Es el mismo mecanismo.

#Cómo funciona la caché de feeds en WordPress

Aquí hay tres mecanismos distintos que es fácil confundir: la caché de feeds externos que descarga WordPress, las respuestas condicionales de tus propios feeds y la caché del CDN.

Feeds externos. fetch_feed() construye un objeto SimplePie a partir de una dirección RSS o Atom y lo devuelve; si falla, devuelve WP_Error. También acepta un array de direcciones y las fusiona en un solo canal. El resultado se guarda en transients, y su duración se cambia con el filtro wp_feed_cache_transient_lifetime (12 horas por defecto). Esto no es la caché de tu /feed/.

declare(strict_types=1);

$rss = fetch_feed( 'https://example.com/feed/' );

if ( ! is_wp_error( $rss ) ) {
	$max   = $rss->get_item_quantity( 5 );
	$items = $rss->get_items( 0, $max );

	foreach ( $items as $item ) {
		printf(
			'<li><a href="%s">%s</a></li>',
			esc_url( $item->get_permalink() ),
			esc_html( $item->get_title() )
		);
	}
}

add_filter(
	'wp_feed_cache_transient_lifetime',
	static function (): int {
		return HOUR_IN_SECONDS;
	}
);

Comprobar is_wp_error() no es un trámite: un servidor de origen caído o un XML mal formado son el pan de cada día, y sin esa condición el widget lanza un fatal error en el frontend. El contenido que devuelve SimplePie pasa por un saneador basado en KSES, pero los títulos y enlaces los escapas igualmente al imprimirlos.

Tus feeds y las respuestas 304. El core gestiona las peticiones condicionales de los feeds sin ningún plugin. En cada petición con la variable feed definida, WP::send_headers() calcula Last-Modified a partir de la fecha de la última modificación de una entrada (en los feeds de comentarios, también de un comentario), genera con ella el ETag y, cuando el cliente envía un If-Modified-Since o un If-None-Match coincidente, responde 304 y termina la petición. Esto incluye los endpoints creados con add_feed, porque pasan por la misma variable. Solo escribes tu propia lógica de 304 para endpoints fuera de este mecanismo, por ejemplo en la REST API.

Tu endpoint /feed/ o /feed/releases/ en el edge:

  • CDN / reverse proxy: fija Cache-Control a conciencia. Para un feed público suele bastar max-age=300. Después de publicar, invalida la ruta /feed/ o versiona el ETag.
  • Object cache: si generas un JSON pesado o combinas CPT con meta, cachea la cadena de respuesta con wp_cache_set y un TTL corto, y límpiala en transition_post_status / save_post.
  • No caches HTML por usuario: el feed es público; personalizar el XML acaba filtrando datos entre suscriptores.
  • Page cache de plugins: algunas cachés de HTML completo sirven /feed/ como si fuera una página, con un Content-Type incorrecto. Excluye las rutas feed de las reglas de caché o fuerza un bypass para is_feed().

Ejemplo de cabecera de caché corta en un feed propio:

header( 'Cache-Control: public, max-age=300' );
header( 'X-Robots-Tag: noindex' ); // opcional, si no quieres indexar el XML

noindex en el feed no desactiva la sindicación. Solo impide indexar el propio XML como “página”. Con Cloudflare u otro edge: una regla de caché para *.xml / la ruta /feed* con TTL corto y Cache Everything solo si el origen devuelve el tipo MIME correcto. Una caché de edge mal configurada puede retener durante horas un documento vacío después de un flush de las reglas de reescritura.

#Cómo desactivar o limitar los feeds RSS de forma segura

Desactivar los feeds “a lo bruto” y sin plan deja enlaces de discovery colgando y marcadores antiguos en los lectores. Orden seguro:

  1. Elimina los enlaces de discovery: remove_action( 'wp_head', 'feed_links', 2 ) y remove_action( 'wp_head', 'feed_links_extra', 3 ), con las prioridades con las que los registra el core.
  2. Decide si las URL antiguas harán un 301 al contenido HTML, devolverán un 410 vacío o seguirán sirviendo XML.
  3. Solo entonces desactiva los manejadores do_feed_*.

El core registra cuatro manejadores: do_feed_rdf, do_feed_rss, do_feed_rss2 y do_feed_atom. No hay manejadores aparte para los comentarios: /comments/feed/ pasa por do_feed_rss2 (o do_feed_atom) con el primer argumento a true. Corte mínimo de los feeds clásicos (deja abierta la opción de un add_feed propio):

declare(strict_types=1);

function wppoland_disable_default_feeds(): void {
	remove_action( 'do_feed_rdf', 'do_feed_rdf', 10 );
	remove_action( 'do_feed_rss', 'do_feed_rss', 10 );
	remove_action( 'do_feed_rss2', 'do_feed_rss2', 10 );
	remove_action( 'do_feed_atom', 'do_feed_atom', 10 );
}
add_action( 'init', 'wppoland_disable_default_feeds' );

function wppoland_feed_gone(): void {
	wp_die(
		esc_html__( 'Este feed se ha desactivado.', 'wppoland' ),
		'',
		array( 'response' => 410 )
	);
}
add_action( 'do_feed_rss2', 'wppoland_feed_gone', 1 );
add_action( 'do_feed_atom', 'wppoland_feed_gone', 1 );

RDF y RSS 0.92, sin manejador, terminan en un 404 de do_feed(); RSS2 y Atom (junto con sus versiones de comentarios) reciben un 410.

Una alternativa más suave: deja RSS2 y recorta Atom/RDF y el feed de comentarios. Los agregadores usan RSS2 de todos modos. El feed de comentarios por sí solo lo desactivas comprobando el argumento del manejador:

declare(strict_types=1);

function wppoland_comments_feed_gone( bool $is_comment_feed ): void {
	if ( $is_comment_feed ) {
		wp_die( esc_html__( 'El feed de comentarios está desactivado.', 'wppoland' ), '', array( 'response' => 410 ) );
	}
}
add_action( 'do_feed_rss2', 'wppoland_comments_feed_gone', 1 );
add_action( 'do_feed_atom', 'wppoland_comments_feed_gone', 1 );
add_filter( 'feed_links_show_comments_feed', '__return_false' );

El filtro feed_links_show_comments_feed elimina del <head> el enlace de discovery al feed de comentarios. No elimines las reglas de reescritura de los feeds sin hacer flush, o tendrás un 404 en lugar de una respuesta controlada.

#Cómo el feed de autor expone a los usuarios de WordPress

/author/admin/feed/ y /author/mariusz/feed/ confirman que un nombre de usuario existe. Junto con /?author=1 (que redirige al archivo), es el vector clásico de enumeración de cuentas antes de un ataque de fuerza bruta o de phishing dirigido.

Cierre total de los archivos de autor (feeds incluidos):

declare(strict_types=1);

function wppoland_disable_author_archives(): void {
	if ( is_author() ) {
		global $wp_query;
		$wp_query->set_404();
		status_header( 404 );
		nocache_headers();
	}
}
add_action( 'template_redirect', 'wppoland_disable_author_archives' );

Si los archivos de autor deben seguir (un blog personal), como mínimo:

  • usa un nicename limpio y no el nombre de usuario en bruto en la URL, si tu modelo lo permite,
  • no publiques en el HTML una lista de autores con sus nombres de usuario,
  • valora reducir el feed de autor a una lista vacía o devolver 404 solo para is_feed() && is_author().

Ten en cuenta que la plantilla RSS2 imprime en cada elemento <dc:creator> con el nombre visible del autor, y Atom el elemento <author>. Si el nombre visible coincide con el nombre de usuario, el feed principal lo revela igual que el archivo de autor.

La REST API (/wp-json/wp/v2/users) es otro tema. Desactivar el feed de autor no cierra la enumeración por REST sin filtros adicionales. Trata el RSS como una más de las superficies que revelan identidades.

No metas en el feed direcciones de correo, tokens, ID internos de pedidos ni contenido private / draft. Los filtros de contenido también actúan en el contexto del feed: un shortcode que en HTML muestra un panel al cliente puede filtrarse en el XML a suscriptores públicos.

El feed de comentarios (/comments/feed/ o el feed de una entrada concreta) se suele pasar por alto en las auditorías. Si los comentarios contienen datos de acceso, números de pedido o datos personales de clientes B2B, desactiva el feed de comentarios con el código de la sección anterior o modera antes de que el comentario llegue al XML. Un canal público de comentarios es una superficie de scraping más, no solo “un extra para los lectores”.

#Cómo crear un feed de podcast con iTunes y enclosure

WordPress no es un alojamiento de archivos de audio, pero sí puede generar un RSS compatible con los directorios. Los requisitos de Apple Podcasts lo dicen claramente: cada episodio debe tener un <enclosure> único con tres componentes (URL, longitud, tipo), cada episodio debe tener un GUID que no cambie nunca y el feed debe contener al menos un episodio. Apple mantiene la lista de etiquetas obligatorias del canal en un documento aparte sobre etiquetas RSS, y es con ese documento con el que comparas el feed antes de enviarlo.

A nivel técnico necesitas:

  • el namespace xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" (URL histórica del namespace; los directorios la reconocen),
  • etiquetas de canal del espacio itunes (por ejemplo itunes:author, itunes:category, itunes:image) y language,
  • elementos con <enclosure url="..." length="..." type="audio/mpeg" /> y, opcionalmente, itunes:duration.

Hooks del core:

declare(strict_types=1);

function wppoland_podcast_ns(): void {
	echo ' xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"';
}
add_action( 'rss2_ns', 'wppoland_podcast_ns' );

function wppoland_podcast_head(): void {
	echo '<itunes:author>' . esc_xml( get_bloginfo( 'name' ) ) . '</itunes:author>';
}
add_action( 'rss2_head', 'wppoland_podcast_head' );

function wppoland_podcast_item_enclosure(): void {
	$url = get_post_meta( get_the_ID(), 'episode_audio_url', true );
	$len = (int) get_post_meta( get_the_ID(), 'episode_audio_length', true );

	if ( ! is_string( $url ) || $url === '' || $len < 1 ) {
		return;
	}

	printf(
		'<enclosure url="%s" length="%d" type="audio/mpeg" />',
		esc_url( $url ),
		$len
	);
}
add_action( 'rss2_item', 'wppoland_podcast_item_enclosure' );

Este código asume que los episodios no tienen relleno el campo enclosure, que rss_enclosure() imprime automáticamente. La función esc_xml() está en el core desde la versión 5.5 y es el escape adecuado para texto dentro de elementos XML.

En producción suele ser mejor un feed aparte (add_feed( 'podcast', ... )) que mezclar entradas normales con episodios. Los directorios validan el canal entero; un solo elemento defectuoso puede tumbar el envío. Mantén los MP3 en una URL estable (CDN), con un length correcto en bytes, porque adivinar la longitud rompe los clientes. Apple recomienda además habilitar en el servidor de archivos las peticiones HEAD y las peticiones por rangos de bytes, de las que depende la reproducción en streaming.

#Cómo añadir JSON Feed a WordPress

JSON Feed es una alternativa a XML para clientes JS. El core de WordPress no lo registra de forma nativa. Lo añades con add_feed( 'json', $callback ) y la cabecera Content-Type: application/feed+json, porque es el tipo MIME que recomienda la especificación.

Esqueleto del callback:

declare(strict_types=1);

function wppoland_json_feed(): void {
	header( 'Content-Type: application/feed+json; charset=utf-8', true );

	$query = new WP_Query(
		array(
			'post_type'           => 'post',
			'post_status'         => 'publish',
			'posts_per_page'      => 20,
			'ignore_sticky_posts' => true,
			'no_found_rows'       => true,
		)
	);

	$items = array();
	foreach ( $query->posts as $post ) {
		$items[] = array(
			'id'             => (string) get_the_guid( $post ),
			'url'            => get_permalink( $post ),
			'title'          => get_the_title( $post ),
			'content_html'   => apply_filters( 'the_content', $post->post_content ),
			'date_published' => get_post_time( DATE_ATOM, true, $post ),
			'date_modified'  => get_post_modified_time( DATE_ATOM, true, $post ),
		);
	}

	echo wp_json_encode(
		array(
			'version'       => 'https://jsonfeed.org/version/1.1',
			'title'         => get_bloginfo( 'name' ),
			'home_page_url' => home_url( '/' ),
			'feed_url'      => home_url( '/feed/json/' ),
			'language'      => get_bloginfo( 'language' ),
			'items'         => $items,
		),
		JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
	);
}

add_action(
	'init',
	static function (): void {
		add_feed( 'json', 'wppoland_json_feed' );
	}
);

JSON Feed no sustituye al RSS en los podcasts ni en los lectores más antiguos. Trátalo como un segundo contrato para tus propios clientes y herramientas internas. Discovery en el HTML: type="application/feed+json".

La especificación exige a nivel de canal los campos version, title e items, y en cada elemento un id (string) y al menos uno de content_html o content_text. La versión 1.1 añadió el campo authors (un array, varios autores por canal o por elemento) y el campo language, y marcó como obsoleto el author individual. Mantén estable el id de cada elemento: la especificación indica que el id no debería cambiar cuando se actualiza el elemento y que los elementos nuevos no deberían reutilizar un id ya usado. Por eso en el ejemplo el id es el guid y no el permalink, que cambia junto con el slug. content_text o content_html: elige uno como fuente de verdad; enviar los dos con contenido desincronizado acaba en vistas previas incoherentes en los clientes.

#Cómo activar WebSub en el feed de WordPress

Un lector sin WebSub consulta el feed a su propio ritmo, así que una entrada nueva llega a los suscriptores con un retraso que depende del lector. WebSub, una recomendación del W3C, invierte la dirección: el editor indica un hub, los suscriptores se registran en el hub y el hub distribuye la actualización cuando el editor avisa de un cambio.

Los requisitos del lado del editor son pocos. La especificación recomienda una cabecera Link con al menos un rel="hub" y exactamente un rel="self", y, en los feeds XML, elementos link incrustados. El editor debe notificar la actualización del tema a los hubs indicados, aunque la especificación deja abierto el mecanismo de notificación. En una nota da un ejemplo habitual en los hubs públicos: una petición POST con las claves hub.mode=publish y hub.url apuntando al recurso modificado.

El core de WordPress no implementa WebSub. En el directorio de plugins está el plugin “WebSub (FKA. PubSubHubbub)”, que añade ambos elementos. A mano queda así:

declare(strict_types=1);

const WPPOLAND_HUB = 'https://hub.example.com/';

function wppoland_websub_links(): void {
	printf( '<atom:link rel="hub" href="%s" />' . "\n", esc_url( WPPOLAND_HUB ) );
}
add_action( 'rss2_head', 'wppoland_websub_links' );

function wppoland_websub_ping( string $new_status, string $old_status, WP_Post $post ): void {
	if ( 'publish' !== $new_status || 'publish' === $old_status || 'post' !== $post->post_type ) {
		return;
	}

	wp_remote_post(
		WPPOLAND_HUB,
		array(
			'body'     => array(
				'hub.mode' => 'publish',
				'hub.url'  => get_feed_link( 'rss2' ),
			),
			'blocking' => false,
		)
	);
}
add_action( 'transition_post_status', 'wppoland_websub_ping', 10, 3 );

rel="self" ya está en la plantilla RSS2 (<atom:link rel="self">), así que solo añades rel="hub". Consulta la dirección del hub y el formato de la notificación en la documentación del hub elegido, porque la especificación no los impone. La verificación de la suscripción (hub.challenge), la caducidad tras hub.lease_seconds y la firma X-Hub-Signature cuando hay secreto las gestionan el hub y el suscriptor, no WordPress.

#Cómo solucionar un error de análisis XML en el feed de WordPress

El error más frecuente con plantillas y plugins propios es un documento que el lector rechaza con un mensaje del tipo XML or text declaration not at start of entity. La declaración <?xml ... ?> tiene que ser el primer carácter de la respuesta. Basta una línea en blanco o un BOM impreso antes para que el parser se niegue a procesar el canal entero.

La causa casi siempre está en un archivo PHP que se carga en cada petición: un espacio o un salto de línea después del ?> de cierre en functions.php o en un plugin, o un archivo guardado en UTF-8 con BOM. La solución correcta es quitar el ?> de cierre de los archivos que solo contienen PHP y guardarlos sin BOM. Para encontrar al culpable, ejecuta curl -s https://dominio/feed/ | head -c 100 | xxd, mira qué hay antes de <?xml y luego desactiva los plugins uno a uno.

Si no puedes corregir de inmediato un plugin de terceros, de forma temporal ayuda un búfer de salida que limpie el principio de la respuesta del feed:

declare(strict_types=1);

function wppoland_clean_feed_output(): void {
	if ( ! is_feed() ) {
		return;
	}

	ob_start(
		static function ( string $buffer ): string {
			// Elimina el BOM y los espacios en blanco antes de la declaración XML.
			return ltrim( preg_replace( '/^\xEF\xBB\xBF/', '', $buffer ) );
		}
	);
}
add_action( 'template_redirect', 'wppoland_clean_feed_output', 1 );

Es un parche, no un arreglo: el búfer no elimina los caracteres impresos antes de que se ejecute template_redirect. Los equipos con tests de integración pueden vigilarlo de forma automática. Los tests de feeds del propio core cargan la plantilla directamente y silencian los avisos sobre cabeceras; el mismo patrón funciona en WP_UnitTestCase:

class Feed_Rss2_Test extends WP_UnitTestCase {
	public function test_rss2_is_well_formed(): void {
		self::factory()->post->create_many( 3 );
		$this->go_to( '/?feed=rss2' );

		ob_start();
		@require ABSPATH . WPINC . '/feed-rss2.php';
		$xml = ob_get_clean();

		$this->assertStringStartsWith( '<?xml', $xml );

		$dom = new DOMDocument();
		$this->assertTrue( $dom->loadXML( $xml ) );
	}
}

La aserción comprueba la salida en bruto sin trim(), porque precisamente los espacios en blanco al principio son el error.

#Cómo gestionar los feeds RSS en un WordPress multilingüe

Con WPML, Polylang o prefijos de idioma propios, WordPress suele exponer varias direcciones de feed. Errores típicos:

  • el feed principal mezcla versiones de idioma porque pre_get_posts no filtra por idioma,
  • el CDN cachea /feed/ sin distinguir ruta o cookie y sirve el contenido de un idioma a los suscriptores de otro,
  • las direcciones del enclosure apuntan al dominio equivocado después del mapeo de dominios,
  • el elemento <language> del canal sale de los ajustes del sitio, no del idioma del contenido de ese feed concreto.

Mantén un endpoint claro por idioma, filtra el idioma o la taxonomía al principio de la consulta y prueba el feed con un lector que envíe de verdad If-None-Match, no solo con una pestaña del navegador. Con varias marcas en una misma instalación, un add_feed( 'marca-a', ... ) aparte queda más limpio que un parámetro de consulta pegado al feed principal: los parámetros fragmentan la caché del CDN, y el ETag del core depende únicamente de la fecha de la última modificación, no de los parámetros.

#Qué comprobar antes de desplegar un feed RSS propio

Antes de llevar los cambios a producción:

  1. Abre /feed/ en el navegador y con curl -I: estado 200 y un Content-Type razonable.
  2. Después de add_feed, activa el plugin / haz flush de las reglas de reescritura y comprueba la nueva URL.
  3. Elimina los rel=alternate sobrantes si desactivas feeds.
  4. Revisa /author/.../feed/ desde el punto de vista de la enumeración.
  5. Si haces un podcast, pasa el XML por el validador del directorio antes de enviarlo.
  6. Tras publicar una entrada de prueba, confirma que el CDN devuelve XML fresco dentro de un TTL aceptable.
  7. Verifica que los shortcodes y los bloques dinámicos del contenido del feed no dejan salir HTML del panel de administración ni datos de sesión.
  8. Envía una segunda petición con la cabecera If-None-Match igual al ETag recibido y comprueba que vuelve un 304.

El RSS de WordPress es una capa de API, no una reliquia. Controlas la consulta, la plantilla, la caché y la superficie de ataque igual que en un endpoint REST. La diferencia es que los clientes de un feed son más antiguos, toleran peor el XML defectuoso y tienden más a cachear la respuesta por su cuenta.

Referencias oficiales: Feeds (Developer Resources), add_feed y WordPress Feeds en la documentación de administración. Antes de escribir otro plugin “RSS Pack”, comprueba cuál de esos tres contratos falta de verdad en tu tema.

Si necesitas ampliar el feed con tipos de contenido propios, campos meta o un canal de podcast aparte dentro de un proyecto de desarrollo para WordPress, empieza por un solo add_feed y una sola plantilla, sin un plugin monolítico “all feeds”.

Siguiente paso

Transforma el artículo en una implementación real

Este bloque refuerza el enlazado interno y lleva al lector al siguiente paso más útil dentro de la arquitectura del sitio.

¿Quieres implementar esto en tu sitio?

Si quieres transformar el artículo en mejoras concretas, rediseño o un plan de implementación, puedo cerrar el alcance y ejecutar.

Cluster relacionado

Explora otros servicios WordPress y base de conocimiento

Refuerza tu negocio con soporte técnico profesional en áreas clave del ecosistema WordPress.

Artículos Relacionados