Todos los artículos

Integraciones y APIs

Importa entradas de otro sitio WordPress con la API REST

Por Rolando Escobar, desarrollador WordPress para agencias · · 6 min de lectura

Cuando el contenido vive en un sitio WordPress y tiene que aparecer en otro, copiarlo a mano no escala, y una exportación XML única queda desactualizada al día siguiente. Necesitas una importación que puedas ejecutar una y otra vez sin crear duplicados.

Esa era la situación del Louis Armstrong House Museum, que publica sus Virtual Exhibits en un sitio WordPress aparte. Escribí el plugin de código abierto Virtual Exhibit Importer para traer esas entradas mediante la API REST de WordPress y crearlas en el sitio principal del museo (puedes ver el caso de estudio). Aquí verás cómo funciona y cómo construir una versión más sólida de la misma idea.

Qué hace el plugin

El plugin agrega una página de administración para usuarios con la capacidad manage_options, con tres botones: Start Import, Force Reimport y Delete All Exhibits. Un script de jQuery conduce la importación paso a paso a través de admin-ajax.php:

  1. Pide /wp-json/wp/v2/posts?per_page=1 y lee la cabecera X-WP-Total para saber cuántas entradas hay.
  2. Solicita una entrada por petición, página por página, y avanza una barra de progreso.
  3. Cada entrada se convierte en un post virtual_exhibit. El ID de origen se guarda en el campo meta original_id y el enlace de origen en original_url, que un meta box de solo lectura muestra al equipo editorial.
  4. Antes de insertar, busca original_id. Si la entrada ya existe se omite, o se actualiza en el modo Force Reimport, así que ejecutar la importación dos veces nunca crea duplicados.
  5. Sigue el enlace wp:featuredmedia de la entrada, descarga la imagen con download_url() y la adjunta con media_handle_sideload().

Una entrada por petición mantiene cortas las llamadas AJAX y hace que la barra de progreso sea exacta, algo útil en hostings con límites de tiempo de PHP estrictos. El costo son dos peticiones HTTP por entrada: la entrada y después su imagen. Para sitios más grandes, o importaciones programadas, encaja mejor un comando de WP-CLI con lotes más grandes. El resto del artículo construye esa versión.

Trae una página de entradas

Pide hasta 100 entradas por petición, el máximo de la API, y agrega _embed=wp:featuredmedia para que los datos de la imagen lleguen en la misma respuesta. Ordenar por ID mantiene estables las páginas si alguien publica en el sitio de origen durante la importación. Guarda la URL de origen en wp-config.php, no en el plugin.

// En wp-config.php: define( 'RW_EXHIBITS_SOURCE', 'https://exhibits.example.org' );

/**
 * Trae una página de entradas del sitio de origen.
 *
 * @return array{posts: array, total_pages: int}|WP_Error
 */
function rw_fetch_source_page( int $page, int $per_page = 50, string $modified_after = '' ): array|WP_Error {
	$args = [
		'page'     => $page,
		'per_page' => min( $per_page, 100 ), // La API REST limita per_page a 100.
		'orderby'  => 'id',
		'order'    => 'asc',
		'_embed'   => 'wp:featuredmedia',
	];
	if ( '' !== $modified_after ) {
		$args['modified_after'] = $modified_after; // ISO 8601. Requiere WordPress 5.7+ en el origen.
	}

	$url      = add_query_arg( array_map( 'rawurlencode', $args ), trailingslashit( RW_EXHIBITS_SOURCE ) . 'wp-json/wp/v2/posts' );
	$response = wp_safe_remote_get( $url, [ 'timeout' => 20 ] );

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

	$code = (int) wp_remote_retrieve_response_code( $response );
	if ( 200 !== $code ) {
		return new WP_Error( 'rw_http', sprintf( 'Source returned HTTP %d for page %d.', $code, $page ) );
	}

	$posts = json_decode( wp_remote_retrieve_body( $response ), true );
	if ( ! is_array( $posts ) ) {
		return new WP_Error( 'rw_json', sprintf( 'Invalid JSON on page %d.', $page ) );
	}

	return [
		'posts'       => $posts,
		'total_pages' => (int) wp_remote_retrieve_header( $response, 'x-wp-totalpages' ),
	];
}

Cada camino de error devuelve un WP_Error: fallos de red, respuestas distintas de 200 y JSON inválido. wp_safe_remote_get() rechaza URLs y redirecciones que apuntan a direcciones locales o privadas, y el timeout explícito reemplaza el valor por defecto de 5 segundos. Si las entradas de origen no son públicas, envía una Application Password en la cabecera Authorization y define las credenciales como constantes en wp-config.php. La referencia del endpoint de posts lista todos los parámetros que se usan aquí.

Mapea los campos y haz seguras las nuevas ejecuciones

La idempotencia es el corazón de cualquier importador: volver a ejecutarlo debe actualizar lo que ya existe, no agregar copias. El plugin lo logra con la clave meta original_id. La versión de abajo hace lo mismo con un agregado: guarda el modified_gmt de origen, así que las entradas sin cambios se omiten sin tocar la base de datos.

function rw_find_by_source_id( int $source_id ): int {
	$ids = get_posts( [
		'post_type'      => 'virtual_exhibit',
		'post_status'    => 'any',
		'meta_key'       => '_rw_source_id',
		'meta_value'     => $source_id,
		'fields'         => 'ids',
		'posts_per_page' => 1,
		'no_found_rows'  => true,
	] );

	return $ids ? (int) $ids[0] : 0;
}

/**
 * Crea o actualiza la copia local de una entrada de origen.
 *
 * @return array{0: string, 1: int|WP_Error} Estado ('created', 'updated', 'skipped' o 'error') e ID de la entrada o error.
 */
function rw_upsert_post( array $item, bool $force = false ): array {
	$post_id = rw_find_by_source_id( (int) $item['id'] );

	// Sin cambios desde la última ejecución: no hay nada que escribir.
	if ( $post_id && ! $force && get_post_meta( $post_id, '_rw_source_modified', true ) === $item['modified_gmt'] ) {
		return [ 'skipped', $post_id ];
	}

	$date_gmt = str_replace( 'T', ' ', $item['date_gmt'] );
	$postarr  = [
		'post_type'     => 'virtual_exhibit',
		'post_status'   => 'publish',
		'post_title'    => wp_strip_all_tags( html_entity_decode( $item['title']['rendered'], ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ),
		'post_name'     => sanitize_title( $item['slug'] ),
		'post_content'  => wp_kses_post( $item['content']['rendered'] ),
		'post_excerpt'  => wp_strip_all_tags( $item['excerpt']['rendered'] ),
		'post_date'     => get_date_from_gmt( $date_gmt ),
		'post_date_gmt' => $date_gmt,
		'meta_input'    => [
			'_rw_source_id'       => (int) $item['id'],
			'_rw_source_url'      => esc_url_raw( $item['link'] ),
			'_rw_source_modified' => $item['modified_gmt'],
		],
	];

	// Ambas funciones esperan datos con barras escapadas. wp_update_post() conserva los campos que no envías.
	$result = $post_id
		? wp_update_post( wp_slash( [ 'ID' => $post_id ] + $postarr ), true )
		: wp_insert_post( wp_slash( $postarr ), true );

	if ( is_wp_error( $result ) ) {
		return [ 'error', $result ];
	}

	return [ $post_id ? 'updated' : 'created', (int) $result ];
}

El guion bajo al inicio oculta las claves meta del panel de campos personalizados. Si ya importaste contenido con el plugin, conserva su clave original_id en lugar de empezar una nueva. Algunas decisiones de mapeo merecen atención:

  • Título. title.rendered contiene entidades HTML como ’. Decodifícalas para que el título guardado sea texto plano.
  • Slug y fecha. Usa el slug y el date_gmt de origen para que las URLs y el orden del archivo coincidan con el original. El plugin arma el slug a partir del título y deja que WordPress feche cada entrada en el momento de la importación.
  • Contenido. content.rendered es el HTML después de ejecutar bloques y shortcodes, no el marcado de bloques. wp_kses_post() elimina todo lo inseguro, y eso incluye los iframe embebidos. Si necesitas los bloques originales, pide context=edit con una Application Password e importa content.raw.
  • Extracto. Cuando el autor no escribió uno, excerpt.rendered se genera automáticamente y puede incluir el texto de «leer más» del tema de origen. Impórtalo solo si el origen usa extractos manuales.
  • Barras invertidas. wp_insert_post() y wp_update_post() esperan datos con barras escapadas. Sin wp_slash(), las barras invertidas del contenido desaparecen.

Descarga la imagen destacada una sola vez

media_sideload_image() descarga un archivo, crea el adjunto y, desde WordPress 5.4, guarda la URL original en el meta _source_url. Esa clave es justo lo que necesitas para no descargar la misma imagen en la siguiente ejecución.

/**
 * Asigna la imagen destacada desde el medio embebido y descarga cada archivo una sola vez.
 *
 * @return bool|WP_Error True si se asigna una miniatura, false si el origen no tiene.
 */
function rw_sync_featured_image( int $post_id, array $item ): bool|WP_Error {
	$media = $item['_embedded']['wp:featuredmedia'][0] ?? null;
	if ( empty( $media['source_url'] ) ) {
		return false;
	}
	$source_url = esc_url_raw( $media['source_url'] );

	// media_sideload_image() guarda la URL original en _source_url (WordPress 5.4+).
	$existing = get_posts( [
		'post_type'      => 'attachment',
		'post_status'    => 'inherit',
		'meta_key'       => '_source_url',
		'meta_value'     => $source_url,
		'fields'         => 'ids',
		'posts_per_page' => 1,
		'no_found_rows'  => true,
	] );

	if ( $existing ) {
		$attachment_id = (int) $existing[0];
	} else {
		require_once ABSPATH . 'wp-admin/includes/media.php';
		require_once ABSPATH . 'wp-admin/includes/file.php';
		require_once ABSPATH . 'wp-admin/includes/image.php';

		$title         = html_entity_decode( $media['title']['rendered'] ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		$attachment_id = media_sideload_image( $source_url, $post_id, sanitize_text_field( $title ), 'id' );
		if ( is_wp_error( $attachment_id ) ) {
			return $attachment_id;
		}
		// Conserva el texto alternativo escrito en el sitio de origen.
		update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $media['alt_text'] ?? '' ) );
	}

	set_post_thumbnail( $post_id, $attachment_id );
	return true;
}

Por defecto acepta URLs jpg, jpeg, jpe, png, gif y webp. El filtro image_sideload_extensions agrega otras, como avif. El texto alternativo viene del objeto de medios de origen, así que el trabajo de accesibilidad hecho en el otro sitio no se pierde. Las imágenes dentro del contenido siguen apuntando al sitio de origen. Eso está bien mientras el origen siga en línea; si lo van a retirar, descarga también esas imágenes y reescribe sus URLs en el contenido.

Ejecútalo con WP-CLI, por lotes

Un botón en el escritorio de WordPress es cómodo, pero una importación larga en una pestaña del navegador se detiene en cuanto alguien la cierra. WP-CLI corre en una terminal sin el límite de tiempo de PHP del servidor web, y es fácil de programar.

if ( defined( 'WP_CLI' ) && WP_CLI ) {
	WP_CLI::add_command( 'rw exhibits import', 'rw_cli_import_exhibits', [
		'shortdesc' => 'Imports or updates posts from the source site.',
		'synopsis'  => [
			[ 'type' => 'assoc', 'name' => 'since', 'optional' => true, 'description' => 'Only posts modified after this ISO 8601 date.' ],
			[ 'type' => 'assoc', 'name' => 'per-page', 'optional' => true, 'description' => 'Posts per request, 1 to 100. Default 50.' ],
			[ 'type' => 'flag', 'name' => 'force', 'optional' => true, 'description' => 'Update posts even if unchanged.' ],
		],
	] );
}

function rw_cli_import_exhibits( array $args, array $assoc_args ): void {
	$since    = (string) ( $assoc_args['since'] ?? '' );
	$per_page = max( 1, min( 100, (int) ( $assoc_args['per-page'] ?? 50 ) ) );
	$force    = (bool) WP_CLI\Utils\get_flag_value( $assoc_args, 'force', false );
	$totals   = [ 'created' => 0, 'updated' => 0, 'skipped' => 0, 'error' => 0 ];
	$page     = 1;

	do {
		$batch = rw_fetch_source_page( $page, $per_page, $since );
		if ( is_wp_error( $batch ) ) {
			WP_CLI::error( $batch->get_error_message() ); // Detiene la ejecución. Volver a ejecutarla es seguro.
		}

		foreach ( $batch['posts'] as $item ) {
			[ $status, $result ] = rw_upsert_post( $item, $force );
			++$totals[ $status ];

			if ( 'error' === $status ) {
				WP_CLI::warning( sprintf( 'Source post %d: %s', $item['id'], $result->get_error_message() ) );
				continue;
			}
			if ( 'skipped' !== $status ) {
				$image = rw_sync_featured_image( $result, $item );
				if ( is_wp_error( $image ) ) {
					WP_CLI::warning( sprintf( 'Image for post %d: %s', $result, $image->get_error_message() ) );
				}
			}
		}

		WP_CLI::log( sprintf( 'Page %d of %d done.', $page, max( 1, $batch['total_pages'] ) ) );
		++$page;
	} while ( $page <= $batch['total_pages'] );

	WP_CLI\Utils\format_items( 'table', [ $totals ], array_keys( $totals ) );
}

Ejecútalo como wp rw exhibits import --user=editor-name para que las entradas nuevas tengan un autor real. Sin el flag global --user, WP-CLI corre sin usuario actual y las entradas nuevas se guardan con un ID de autor 0. Como cada paso es idempotente, una ejecución fallida se puede volver a lanzar sin más.

La primera importación corre sin --since. Después, un cron del sistema puede ejecutar el comando cada hora con una marca de tiempo un poco anterior a la ejecución previa. Que las ventanas se solapen no cuesta nada, y modified_after requiere WordPress 5.7 o posterior en el sitio de origen. Si el hosting no ofrece cron del sistema, WP-Cron puede llamar a las mismas funciones desde un evento por hora que procese una página por ejecución. WP-Cron solo se dispara cuando el sitio recibe visitas, así que tómalo como «en algún momento», no como «a tiempo». El recetario de comandos de WP-CLI explica el registro de comandos con más detalle.

Qué vigilar

  • Revisa capacidades, no solo nonces. Si mantienes una interfaz con botones en el escritorio como la del plugin, llama a current_user_can( 'manage_options' ) en cada handler AJAX. Un nonce demuestra que la petición salió de tu página. No demuestra que el usuario tenga permiso para ejecutarla, y una acción de «borrar todo» necesita ambas comprobaciones.
  • Las eliminaciones no se sincronizan. Una entrada borrada en el origen sigue en el destino. Compara los IDs de origen de vez en cuando y pasa a borrador las entradas que falten en lugar de eliminarlas.
  • Las taxonomías necesitan su propio mapeo. Empareja categorías y etiquetas por slug, creando términos cuando haga falta, nunca por ID.
  • Elige una URL canónica. Si ambos sitios siguen públicos con el mismo texto, decide cuál es la canónica y decláralo, o los buscadores lo decidirán por ti.
  • Advierte por elemento, detente por ejecución. Una entrada con problemas debe registrar una advertencia y seguir. Un fallo de red debe detener la ejecución con un mensaje claro.

Este es el tipo de trabajo que hago para clientes: Plugins e integraciones