Como crear un endpoint para búsquedas por relevancia y sinónimos en WordPress

Contents

Introducción

Este artículo explica, con todo lujo de detalles, cómo crear en WordPress un endpoint REST para búsquedas por relevancia que además soporte sinónimos. La solución propuesta funciona sin motores externos (Elasticsearch/Solr) y aprovecha capacidades de MySQL (FULLTEXT) junto con buenas prácticas de seguridad, paginación y cache. Se ejemplifica el registro del endpoint, la expansión por sinónimos, la generación de una consulta con relevancia y la devolución de resultados ordenados por puntuación.

Requisitos y decisiones de diseño

  • WordPress 4.7 (WP REST API integrado) y acceso a la base de datos MySQL/MariaDB para crear índices FULLTEXT.
  • Relevancia: se usará MATCH…AGAINST para calcular puntuaciones en post_title y post_content. Si no está disponible, la lógica cae a LIKE (menos precisa).
  • Sinónimos: tabla simple o array configurable. El tutorial muestra un ejemplo con un array en código y una opción para almacenar sinónimos en una tabla.
  • Seguridad y rendimiento: sanitización de entradas, consultas preparadas con wpdb, cache con transients, límites y paginación.

Paso 1 — Preparar la base de datos: índices FULLTEXT

Para obtener puntuaciones de relevancia con MATCH…AGAINST es recomendable añadir índices FULLTEXT sobre las columnas que se desean buscar (post_title y post_content). Ejecute este SQL en su base de datos (ajuste el prefijo de tablas si procede):

ALTER TABLE wp_posts
  ADD FULLTEXT ft_title_content (post_title, post_content)

Si su tabla tiene ya un índice o si usa versiones antiguas de MySQL, compruebe compatibilidad y tamaños de texto. Para WordPress multisite o prefijos distintos cambie wp_posts por su tabla real.

Paso 2 — Registrar el endpoint REST

El endpoint será, por ejemplo, /wp-json/custom/v1/search. Debe registrar la ruta y declarar parámetros esperados: q (query), page, per_page, synonyms (opcional para activar/desactivar), fields (qué campos devolver), etc.

 GET,
        callback            => custom_search_endpoint_handler,
        permission_callback => __return_true,
        args                => array(
            q => array(
                required => true,
                type     => string,
            ),
            page => array(
                required => false,
                default  => 1,
                type     => integer,
            ),
            per_page => array(
                required => false,
                default  => 10,
                type     => integer,
            ),
            synonyms => array(
                required => false,
                default  => true,
                type     => boolean,
            ),
        ),
    ))
})
?>

Paso 3 — Lógica del handler: expansión de sinónimos y consulta por relevancia

La función del handler debe:

  1. Recibir y sanitizar parámetros.
  2. Expandir la consulta con sinónimos (si está activado).
  3. Construir consulta MATCH…AGAINST que devuelva una puntuación.
  4. Cargar los posts ordenados por esa puntuación y devolver JSON con formato sencillo.
  5. Usar cache para peticiones repetidas.

Ejemplo completo del handler (incluye cache, preparación, seguridad y mapeo a posts de WP):

get_param(q) )
    page     = max( 1, (int) request->get_param(page) )
    per_page = min( 50, max( 1, (int) request->get_param(per_page) ) )
    use_syn  = filter_var( request->get_param(synonyms), FILTER_VALIDATE_BOOLEAN )

    if ( empty( q ) ) {
        return new WP_Error( empty_query, El parámetro q es obligatorio, array( status => 400 ) )
    }

    // Normalizar la consulta
    query_terms = preg_split( /s /, mb_strtolower( q ) )
    query_terms = array_filter( query_terms )

    // Generar clave de cache
    cache_key = custom_search_ . md5( q .  . page .  . per_page .  . (use_syn ? 1 : 0) )
    cached = get_transient( cache_key )
    if ( cached ) {
        return rest_ensure_response( cached )
    }

    // Definir sinónimos (puede cargarse desde opción o tabla)
    synonyms_map = array(
        auto => array(coche,automóvil),
        ordenador => array(pc,computadora,laptop),
        teléfono => array(móvil,celular),
    )

    // Expandir términos con sinónimos
    expanded_terms = query_terms
    if ( use_syn ) {
        foreach ( query_terms as term ) {
            if ( isset( synonyms_map[ term ] ) ) {
                expanded_terms = array_merge( expanded_terms, synonyms_map[ term ] )
            }
            // Búsqueda por raíz simple (opcional): buscar claves que contengan el término
            foreach ( synonyms_map as key => vals ) {
                if ( strpos( key, term ) !== false ) {
                    expanded_terms[] = key
                    expanded_terms = array_merge( expanded_terms, vals )
                }
            }
        }
    }
    expanded_terms = array_unique( expanded_terms )

    // Preparar cadena para MATCH...AGAINST: usar operadores booleanos si se desea
    // Aquí usamos modo natural con escape simple. Para booleano se podría construir:  term1  term2
    match_search = implode(  , array_map( function( t ) use ( wpdb ) {
        // eliminar comillas problemáticas
        t = preg_replace(/[] /, , t)
        return wpdb->esc_like( t )
    }, expanded_terms ) )

    // Si no hay términos válidos, devolver vacío
    if ( empty( match_search ) ) {
        response = array( total => 0, results => array() )
        set_transient( cache_key, response, HOUR_IN_SECONDS )
        return rest_ensure_response( response )
    }

    // Construir consulta SQL usando MATCH...AGAINST sobre post_title y post_content
    posts_table = wpdb->posts
    offset = ( page - 1 )  per_page

    // Evitar que devuelva posts no publicados
    status_clause = AND {posts_table}.post_status = publish AND {posts_table}.post_type IN (post,page,product)

    // Preparar el SQL. IMPORTANTE: usamos escaped search y prepared statements para seguridad.
    sql = wpdb->prepare(
        
        SELECT ID,
               post_title,
               post_excerpt,
               post_date,
               MATCH (post_title, post_content) AGAINST (%s IN NATURAL LANGUAGE MODE) AS relevance
        FROM {posts_table}
        WHERE MATCH (post_title, post_content) AGAINST (%s IN NATURAL LANGUAGE MODE)
          {status_clause}
        ORDER BY relevance DESC
        LIMIT %d, %d
        ,
        match_search,
        match_search,
        offset,
        per_page
    )

    rows = wpdb->get_results( sql )

    // Si no hay resultados, devolver objeto vacío
    if ( empty( rows ) ) {
        response = array( total => 0, results => array() )
        set_transient( cache_key, response, HOUR_IN_SECONDS )
        return rest_ensure_response( response )
    }

    // Mapear resultados a estructura limpia
    results = array()
    foreach ( rows as r ) {
        results[] = array(
            id        => (int) r->ID,
            title     => r->post_title,
            excerpt   => r->post_excerpt,
            date      => r->post_date,
            relevance => (float) r->relevance,
            permalink => get_permalink( r->ID ),
        )
    }

    // Obtener total aproximado (opcional): se puede hacer COUNT sobre MATCH o hacer segunda consulta
    // Para rendimiento, devolvemos total = count(results) y un flag partial si se quiere
    response = array(
        total   => count( results ),
        page    => page,
        per_page=> per_page,
        results => results,
    )

    // Guardar cache breve
    set_transient( cache_key, response, 30  MINUTE_IN_SECONDS )

    return rest_ensure_response( response )
}
?>

Notas sobre el handler

  • Se ha limitado per_page a 50 para evitar consultas pesadas.
  • Se usa MATCH…AGAINST en modo NATURAL. Para búsquedas booleanas y control fino, cambie a IN BOOLEAN MODE y construya la cadena con operadores y -.
  • Si su tabla no tiene índice FULLTEXT la consulta MATCH será lenta o fallará: compruebe la creación del índice.
  • El ejemplo restringe resultados a post_type comunes ajuste según su sitio.

Paso 4 — Administrar sinónimos de forma más avanzada

Para un control más robusto cree una tabla de sinónimos o una opción WP que contenga el mapa. Ejemplo de tabla:

CREATE TABLE wp_search_synonyms (
  id INT AUTO_INCREMENT PRIMARY KEY,
  term VARCHAR(191) NOT NULL,
  synonym VARCHAR(191) NOT NULL,
  UNIQUE(term, synonym)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4

Consulta para cargar sinónimos en el handler:

synonyms_map = array()
rows = wpdb->get_results( SELECT term, synonym FROM {wpdb->prefix}search_synonyms )
foreach ( rows as row ) {
    synonyms_map[ row->term ][] = row->synonym
}

Paso 5 — Ejemplo de front-end que consume el endpoint

// Ejemplo simple con fetch
async function searchSite(q, page = 1, per_page = 10) {
  const params = new URLSearchParams({
    q: q,
    page: page,
    per_page: per_page,
    synonyms: 1
  })

  const resp = await fetch(/wp-json/custom/v1/search?   params.toString(), {
    method: GET,
    headers: {
      Accept: application/json
    }
  })

  if (!resp.ok) {
    const err = await resp.json()
    throw new Error(err.message  Error en la búsqueda)
  }

  const data = await resp.json()
  return data
}

// Uso
searchSite(ordenador portátil, 1, 10).then(data => {
  console.log(data)
})

Mejoras y consideraciones adicionales

  • Stemming y análisis lingüístico: MySQL no hace stemming avanzado. Para búsquedas por relevancia en idiomas complejos considere usar Elasticsearch o Algolia.
  • Relevancia por campo: puede ponderar título más que contenido con: MATCH(title) AGAINST(…) 2 MATCH(content) AGAINST(…) 1.
  • Paginación total: si necesita el conteo total real, realice una segunda consulta COUNT(…) con MATCH o use SQL_CALC_FOUND_ROWS (no recomendado por rendimiento).
  • Seguridad: todas las entradas usan wpdb->prepare y funciones de escape. Si expone parámetros de filtrado, valide tipos y límites.
  • Cache: ajuste la duración del transient según la frecuencia de cambios del contenido. Purge la cache al guardar posts (hook save_post) si quiere respuestas actualizadas.
  • Rate limiting: si su endpoint puede ser abusado implemente controles de tasa por IP o por usuario.

Ejemplo: ponderar título y contenido y añadir highlight simple

Ejemplo de SQL que pondera título con factor 2 y contenido con factor 1, y que devuelve fragmento resaltado (simple):

prepare(
    
    SELECT ID,
           post_title,
           post_excerpt,
           MATCH(post_title) AGAINST (%s)  2   MATCH(post_content) AGAINST (%s) AS score
    FROM {wpdb->posts}
    WHERE MATCH(post_title, post_content) AGAINST (%s)
      AND post_status = publish
    ORDER BY score DESC
    LIMIT %d, %d
    ,
    search, search, search, offset, per_page
)
rows = wpdb->get_results(sql)

// Para highlight simple (no librería): buscar términos y envolver en 
foreach (rows as r) {
    snippet = wp_trim_words( strip_tags( r->post_content ), 30 )
    foreach (expanded_terms as t) {
        snippet = preg_replace(/( . preg_quote(t, /) . )/i, 1, snippet)
    }
    // mapear resultado con snippet resaltado
}
?>

Consideraciones finales

Con los pasos anteriores dispone de un endpoint REST personalizado que:

  • Realiza búsquedas por relevancia usando índices FULLTEXT.
  • Expande la consulta con sinónimos configurables.
  • Devuelve resultados paginados con puntuación de relevancia y permalink.
  • Incluye caching y límites para proteger rendimiento.

Implemente control de versiones para el endpoint y pruebas con diferentes consultas para ajustar el mapa de sinónimos, pesos de campos y duración de la cache. Adoptar un motor de búsqueda dedicado será la opción a considerar cuando el volumen o la complejidad de consultas supere lo que MySQL puede hacer de forma eficiente.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Deja una respuesta

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