Base de conocimiento privada en WordPress con datos de Shopify
Por Rolando Escobar, desarrollador WordPress para agencias · · 5 min de lectura
Los agentes de un call center responden preguntas sobre productos todo el día, y los datos copiados a mano quedan desactualizados en cuanto alguien edita la tienda. Una base de conocimiento privada que lee los datos de producto desde Shopify resuelve eso, siempre que de verdad sea privada y no sature la API.
El NaturalSlim Resource Center es ese tipo de herramienta: un sitio WordPress privado, totalmente a la medida, que funciona como base de conocimiento del equipo de call center de NaturalSlim. Junto a diccionarios con búsqueda, una biblioteca de documentos, el seguimiento de qué miembros del personal están conectados y el inicio de sesión en dos pasos, muestra información de productos que obtiene de la tienda Shopify de NaturalSlim a través de su API. Este artículo muestra cómo construir el sitio privado y la parte de productos de una herramienta así. El código es una versión de tutorial, no el código del proyecto.
Primero, cierra el sitio
La privacidad tiene que cubrir tres puertas: las páginas del front end, la REST API y los buscadores. Empieza con un rol para los agentes y una capacidad que cada petición protegida verifique:
register_activation_hook( __FILE__, 'rw_kb_add_roles' );
function rw_kb_add_roles(): void {
add_role( 'rw_agent', 'Call-center agent', array( 'read' => true, 'rw_view_kb' => true ) );
get_role( 'administrator' )->add_cap( 'rw_view_kb' );
}
// Toda petición del front end requiere un usuario con sesión que pueda ver la base de conocimiento.
add_action( 'template_redirect', 'rw_kb_require_access', 0 );
function rw_kb_require_access(): void {
if ( ! is_user_logged_in() ) {
// Devuelve a cada persona a la página que pidió después de iniciar sesión.
wp_safe_redirect( wp_login_url( esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ?? '/' ) ) ) );
exit;
}
if ( ! current_user_can( 'rw_view_kb' ) ) {
wp_die( esc_html__( 'You do not have access to this knowledge base.', 'rw-kb' ), 403 );
}
}
// Sin acceso anónimo a la REST API (patrón del REST API Handbook).
add_filter( 'rest_authentication_errors', function ( $result ) {
if ( true === $result || is_wp_error( $result ) ) {
return $result;
}
if ( ! is_user_logged_in() ) {
return new WP_Error( 'rest_not_logged_in', __( 'You are not currently logged in.', 'rw-kb' ), array( 'status' => 401 ) );
}
return $result;
} );
// Doble protección: pide a los rastreadores que no indexen.
add_filter( 'wp_robots', 'wp_robots_no_robots' );
add_action( 'send_headers', function (): void {
header( 'X-Robots-Tag: noindex, nofollow', true );
} );
Por qué está cada parte:
- El filtro de REST no es opcional. Las peticiones REST nunca llegan a
template_redirect, así que sin él/wp-json/wp/v2/postsle entregaría tus artículos privados a cualquiera. El código sigue el ejemplo del REST API Handbook. - Los agentes reciben
ready una capacidad propia. No necesitan el escritorio, así que oculta la barra de herramientas para ellos con el filtroshow_admin_bar. Los supervisores que editan contenido reciben otro rol con capacidades de edición. - Las señales de noindex son un respaldo. El muro de inicio de sesión ya deja fuera a los rastreadores, pero una página mal configurada o una copia de staging pueden filtrarse, y el encabezado no cuesta nada.
- Los archivos subidos no están cubiertos. El servidor web entrega los archivos de
wp-content/uploadsdirectamente, sin cargar WordPress. Si la base de conocimiento tiene una biblioteca de documentos, guarda esos archivos fuera de la carpeta pública de uploads o sírvelos con un handler de PHP que primero verifique la capacidad.
Agrega también el inicio de sesión en dos pasos. El Resource Center lo tiene, y en un sitio que expone material interno debería ser lo normal. El plugin Two Factor es una buena base.
Habla con Shopify mediante la GraphQL Admin API
Shopify considera la REST Admin API como legacy desde el 1 de octubre de 2024, así que las integraciones nuevas deberían usar la GraphQL Admin API. Crea una app para la tienda solo con el scope de acceso read_products y guarda su token de acceso de la Admin API en wp-config.php, junto con el dominio de la tienda y una versión de API fija. Shopify publica una versión nueva cada trimestre, así que planea actualizar esa constante al menos una vez al año.
// wp-config.php: RW_SHOPIFY_SHOP ('tu-tienda.myshopify.com'), RW_SHOPIFY_API_VERSION ('2026-07'), RW_SHOPIFY_TOKEN.
function rw_kb_shopify_query( string $query, array $variables = array() ): array|WP_Error {
$response = wp_remote_post(
sprintf( 'https://%s/admin/api/%s/graphql.json', RW_SHOPIFY_SHOP, RW_SHOPIFY_API_VERSION ),
array(
'timeout' => 15,
'headers' => array(
'Content-Type' => 'application/json',
'X-Shopify-Access-Token' => RW_SHOPIFY_TOKEN,
),
// El cast a objeto envía las variables vacías como {} en lugar de [].
'body' => wp_json_encode( array( 'query' => $query, 'variables' => (object) $variables ) ),
)
);
if ( is_wp_error( $response ) ) {
return $response;
}
$code = (int) wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 200 !== $code || ! is_array( $body ) ) {
return new WP_Error( 'rw_shopify_http', sprintf( 'Shopify returned HTTP %d.', $code ) );
}
if ( ! empty( $body['errors'] ) ) {
$throttled = 'THROTTLED' === ( $body['errors'][0]['extensions']['code'] ?? '' );
return new WP_Error(
$throttled ? 'rw_shopify_throttled' : 'rw_shopify_graphql',
(string) ( $body['errors'][0]['message'] ?? 'GraphQL error.' ),
$body['extensions']['cost'] ?? null
);
}
return $body;
}
Fíjate en el manejo de errores. Cuando la API de GraphQL te limita, el estado HTTP sigue siendo 200 y el problema está en el arreglo errors, así que revisar solo el código de estado no alcanza.
Sincroniza con un horario, no con cada vista
Los agentes abren páginas de productos todo el tiempo durante las llamadas. Llamar a Shopify en cada vista agrega latencia y consume el límite de tasa. En cambio, una tarea de cron descarga el catálogo, se queda con lo que muestra la base de conocimiento y lo guarda en un transient.
La GraphQL Admin API limita según el costo calculado de cada consulta. Cada respuesta informa el costo en extensions.cost, incluido un throttleStatus con los puntos disponibles (currentlyAvailable) y la tasa de recuperación por segundo (restoreRate). La sincronización lee esos valores y espera antes de pedir la siguiente página, en lugar de adivinar:
const RW_KB_PRODUCTS_QUERY = <<<'GRAPHQL'
query Products($cursor: String) {
products(first: 50, after: $cursor, query: "status:active") {
pageInfo { hasNextPage endCursor }
nodes { handle title descriptionHtml variants(first: 20) { nodes { title sku price } } }
}
}
GRAPHQL;
add_action( 'rw_kb_sync_products', 'rw_kb_sync_products' );
register_activation_hook( __FILE__, function (): void {
if ( ! wp_next_scheduled( 'rw_kb_sync_products' ) ) {
wp_schedule_event( time(), 'hourly', 'rw_kb_sync_products' );
}
} );
function rw_kb_sync_products(): void {
$products = array();
$cursor = null;
do {
$result = rw_kb_shopify_query( RW_KB_PRODUCTS_QUERY, array( 'cursor' => $cursor ) );
if ( is_wp_error( $result ) ) {
error_log( 'Shopify sync stopped: ' . $result->get_error_message() );
return; // Conserva la caché anterior.
}
$page = $result['data']['products'];
foreach ( $page['nodes'] as $node ) {
$products[ $node['handle'] ] = array(
'title' => sanitize_text_field( $node['title'] ),
'description' => wp_kses_post( $node['descriptionHtml'] ),
'variants' => $node['variants']['nodes'],
);
}
$cursor = $page['pageInfo']['endCursor'];
// Espera si la siguiente página no cabe en el bucket del límite de tasa.
$cost = $result['extensions']['cost'] ?? null;
if ( $cost && $cost['throttleStatus']['currentlyAvailable'] < $cost['requestedQueryCost'] ) {
$missing = $cost['requestedQueryCost'] - $cost['throttleStatus']['currentlyAvailable'];
sleep( (int) ceil( $missing / max( 1, $cost['throttleStatus']['restoreRate'] ) ) );
}
} while ( $page['pageInfo']['hasNextPage'] );
set_transient( 'rw_kb_products', $products, 6 * HOUR_IN_SECONDS );
update_option( 'rw_kb_products_synced_at', time(), false );
}
El transient dura seis horas y la tarea corre cada hora, así que una ejecución fallida, o incluso varias, no vacía la base de conocimiento. Si una página falla, la sincronización se detiene sin tocar los datos anteriores. Elimina el evento con wp_clear_scheduled_hook() al desactivar el plugin.
WP-Cron solo se ejecuta cuando alguien carga una página, y un sitio privado tiene poco tráfico fuera del horario laboral. Conecta WP-Cron al programador de tareas del sistema para que la sincronización corra a tiempo. Si los agentes necesitan ver los cambios en segundos, suscríbete a los webhooks de actualización de productos de Shopify, verifica su encabezado HMAC y actualiza solo ese producto.
Custom post type o render al vuelo
Con los datos en caché, puedes mostrarlos directo desde la caché o reflejar cada producto en un custom post type:
| Enfoque | Sirve para | Costos |
|---|---|---|
| Mostrar desde la caché | Catálogos pequeños donde los agentes solo necesitan los datos actuales del producto | Los productos no aparecen en la búsqueda de WordPress, y una caché muy grande puede no caber en una caché de objetos |
| Reflejar en un CPT | Agregar notas internas y guiones de llamada a los productos, y buscarlos junto con el resto de la base de conocimiento | Más código: crear, actualizar y mandar a la papelera entradas, emparejadas por el ID de Shopify en post meta |
| Llamar a la API en cada vista | Nada, en la práctica | Páginas lentas, presión sobre el límite de tasa y una página rota cada vez que Shopify no responde |
Para el enfoque de caché, un shortcode permite que los editores inserten un producto en cualquier artículo:
add_shortcode( 'rw_product', 'rw_kb_product_shortcode' );
function rw_kb_product_shortcode( $atts ): string {
$atts = shortcode_atts( array( 'handle' => '' ), $atts, 'rw_product' );
$handle = sanitize_title( $atts['handle'] );
$products = get_transient( 'rw_kb_products' );
$product = is_array( $products ) ? ( $products[ $handle ] ?? null ) : null;
if ( ! $product ) {
return '<p>' . esc_html__( 'Product data is not available right now.', 'rw-kb' ) . '</p>';
}
ob_start();
?>
<section class="rw-product" aria-labelledby="rw-product-<?php echo esc_attr( $handle ); ?>">
<h2 id="rw-product-<?php echo esc_attr( $handle ); ?>"><?php echo esc_html( $product['title'] ); ?></h2>
<?php echo wp_kses_post( $product['description'] ); ?>
<table>
<thead><tr><th scope="col">Variant</th><th scope="col">SKU</th><th scope="col">Price</th></tr></thead>
<tbody>
<?php foreach ( $product['variants'] as $variant ) : ?>
<tr>
<td><?php echo esc_html( $variant['title'] ); ?></td>
<td><?php echo esc_html( $variant['sku'] ); ?></td>
<td><?php echo esc_html( $variant['price'] ); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p><?php printf( esc_html__( 'Synced from Shopify %s ago.', 'rw-kb' ), esc_html( human_time_diff( (int) get_option( 'rw_kb_products_synced_at' ) ) ) ); ?></p>
</section>
<?php
return (string) ob_get_clean();
}
La línea de «sincronizado hace» importa durante una llamada. Si un cliente menciona un precio que no coincide, el agente puede ver qué tan recientes son los datos antes de contradecirlo.
Qué vigilar
- Sanitiza el HTML de Shopify.
descriptionHtmles lo que alguien escribió en el admin de la tienda. Pásalo porwp_kses_post()antes de guardarlo y escapa todos los demás campos al mostrarlos. - Mantén el token en el servidor. Todas las llamadas a Shopify ocurren en PHP. Nunca le pases el token a JavaScript y dale a la app solo los scopes que necesita para leer.
- Vigila el tamaño de la caché. El límite por defecto de Memcached es de 1 MB por elemento. Un catálogo grande en un solo transient puede fallar al guardarse sin avisar, lo cual es una buena razón para pasar a un CPT o a las bulk operations de Shopify.
- Los precios están en la moneda de la tienda. El campo
pricees un string decimal, así que indica la moneda en la plantilla. - Da de baja a las personas. Una herramienta privada solo es privada mientras se eliminan las cuentas viejas. Elimina las cuentas de los agentes cuando se van y revisa los roles cada pocos meses.
Este es el tipo de trabajo que hago para clientes: Plugins e integraciones