6b61250b0b
Este repo llevaba desde el 28-jun sin actualizarse (salvo sync_carta_from_prod.py).
Se pone al dia con 3 semanas de trabajo que solo vivian en el checkout local
completo del proyecto:
- Fix del buscador (issue #178): AND obligatorio en FULLTEXT (antes devolvia
practicamente todo el sitio con cualquier busqueda), exclusion de paginas
indice residuales de la migracion K2, soporte de frase exacta entre comillas.
- Nuevos mu-plugins ya desplegados a prod: fea-carta-id-api, fea-cloudflare-realip,
fea-crear-autor-api, fea-gsc-verification, fea-legacy-redirect (cutover
Joomla->WP, ver issue #162).
- TTS multi-voz por autor, scripts de traduccion, sync de audio a prod,
homenajes Mardones/Galarreta.
Se mantiene la estructura plana del repo (mu-plugins/ + scripts/, sin el
wordpress/wp-content/ del checkout completo) tal y como documenta el README:
es la convencion establecida para este repo, mas limpia para quien solo
necesita leer el codigo. Anadido .gitignore (cache de Python que no debia
commitearse).
Fuente: checkout local completo del proyecto, rama main (commit 69e849d).
132 lines
5.6 KiB
PHP
132 lines
5.6 KiB
PHP
<?php
|
|
/**
|
|
* Plugin Name: Fe Adulta — Motor FULLTEXT (#8)
|
|
* Description: Sustituye el LIKE nativo de WP por MATCH AGAINST (MySQL FULLTEXT, InnoDB,
|
|
* Boolean Mode) cuando se hace una búsqueda por texto (/?s=…). Ordena por
|
|
* relevancia FULLTEXT si no se pide otro criterio de orden. Degradación elegante:
|
|
* si no hay término o el índice FULLTEXT no existe, usa el comportamiento nativo.
|
|
* Convive con fea-search-advanced.php (filtros pre_get_posts de autor/cat/cita/fecha).
|
|
* Version: 1.3
|
|
*/
|
|
if (!defined('ABSPATH')) exit;
|
|
|
|
/**
|
|
* Comprueba (cacheado) que existe el índice FULLTEXT 'fea_ft' en wp_posts.
|
|
* Si no existe, el motor se degrada al comportamiento nativo para no romper la búsqueda
|
|
* con un error SQL (MATCH AGAINST requiere el índice).
|
|
*/
|
|
function fea_ft_index_exists(): bool {
|
|
static $cached = null;
|
|
if ($cached !== null) return $cached;
|
|
|
|
// Cache persistente 12h vía transient para evitar el SHOW INDEX en cada request.
|
|
$t = get_transient('fea_ft_index_exists');
|
|
if ($t !== false) {
|
|
$cached = ($t === '1');
|
|
return $cached;
|
|
}
|
|
|
|
global $wpdb;
|
|
$found = (int) $wpdb->get_var($wpdb->prepare(
|
|
"SELECT COUNT(*) FROM information_schema.STATISTICS
|
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND INDEX_NAME = 'fea_ft'",
|
|
$wpdb->posts
|
|
));
|
|
$cached = $found > 0;
|
|
set_transient('fea_ft_index_exists', $cached ? '1' : '0', 12 * HOUR_IN_SECONDS);
|
|
return $cached;
|
|
}
|
|
|
|
/**
|
|
* Calcula el término FULLTEXT en Boolean Mode.
|
|
*
|
|
* Caso normal (sin comillas): cada palabra requerida con prefijo + y sufijo *. El '+'
|
|
* fuerza AND entre palabras — sin él, Boolean Mode las une con OR implícito y cualquier
|
|
* post que contenga UNA sola palabra común (p.ej. "ser") entra en el resultado, lo que en
|
|
* la práctica devolvía casi todo el sitio (~24.700 de ~24.780 posts, bug #8 reportado en
|
|
* feedback: "Tu verdadero ser" devolvía cientos de artículos irrelevantes).
|
|
*
|
|
* Caso frase exacta (entrecomillado, ej. `"tu verdadero ser"`): Boolean Mode soporta
|
|
* nativamente búsqueda de frase entre comillas dobles (coincidencia de adyacencia, no
|
|
* solo de palabras sueltas). Reenviamos el contenido saneado tal cual entre comillas, sin
|
|
* partirlo en palabras con + ni *, para que el usuario que escribe entre comillas obtenga
|
|
* de verdad una búsqueda de cadena exacta.
|
|
*
|
|
* Devuelve '' si el término sanitizado queda vacío.
|
|
*/
|
|
function fea_ft_boolean_term(string $raw): string {
|
|
// Frase exacta: todo el término entre comillas dobles.
|
|
if (preg_match('/^"(.*)"$/us', $raw, $m)) {
|
|
$phrase = trim(substr(preg_replace('/[^\p{L}\p{N}\s\'\-]/u', '', $m[1]), 0, 200));
|
|
if ($phrase === '') return '';
|
|
return '"' . $phrase . '"';
|
|
}
|
|
|
|
$term = trim(substr(preg_replace('/[^\p{L}\p{N}\s\'\-]/u', '', $raw), 0, 200));
|
|
if ($term === '') return '';
|
|
|
|
global $wpdb;
|
|
$words = preg_split('/\s+/', $term);
|
|
|
|
// Descartamos palabras por debajo de innodb_ft_min_token_size (3 en este servidor):
|
|
// MySQL las excluye del índice, y forzarlas igualmente como término obligatorio ('+')
|
|
// no aporta nada al filtrado (WP igual las ignora) pero SÍ rompe el cálculo de
|
|
// relevancia: si un término '+' no existe en el índice, MATCH()...AGAINST() en modo
|
|
// boolean puede devolver 0 aunque la fila cumpla el resto de términos, dejando el
|
|
// ORDER BY sin poder distinguir resultados relevantes de irrelevantes.
|
|
$words = array_values(array_filter($words, fn($w) => mb_strlen($w) >= 3));
|
|
if (empty($words)) return '';
|
|
|
|
return implode(' ', array_map(fn($w) => '+' . $wpdb->esc_like($w) . '*', $words));
|
|
}
|
|
|
|
/**
|
|
* Reemplaza la cláusula WHERE de búsqueda por MATCH AGAINST.
|
|
* Hook: posts_search (filtra el WHERE que WP construye para /?s=).
|
|
*/
|
|
add_filter('posts_search', function (string $search, WP_Query $q): string {
|
|
if (is_admin() || !$q->is_main_query() || !$q->is_search()) return $search;
|
|
if (!fea_ft_index_exists()) return $search; // degradación elegante
|
|
|
|
$raw = trim((string) $q->get('s'));
|
|
if ($raw === '') return $search;
|
|
|
|
$ft_term = fea_ft_boolean_term($raw);
|
|
if ($ft_term === '') return $search;
|
|
|
|
global $wpdb;
|
|
$ft_esc = esc_sql($ft_term);
|
|
|
|
// WP construye " AND (...) " para la búsqueda; devolvemos un bloque AND compatible.
|
|
return " AND (MATCH({$wpdb->posts}.post_title, {$wpdb->posts}.post_content) AGAINST ('{$ft_esc}' IN BOOLEAN MODE)) ";
|
|
}, 10, 2);
|
|
|
|
/**
|
|
* Ordena por relevancia FULLTEXT cuando no se especifica otro orden.
|
|
* Hook: posts_clauses (permite modificar SELECT y ORDER BY juntos).
|
|
*/
|
|
add_filter('posts_clauses', function (array $clauses, WP_Query $q): array {
|
|
if (is_admin() || !$q->is_main_query() || !$q->is_search()) return $clauses;
|
|
if (!fea_ft_index_exists()) return $clauses; // degradación elegante
|
|
|
|
$raw = trim((string) $q->get('s'));
|
|
if ($raw === '') return $clauses;
|
|
|
|
// Sólo reordenamos por relevancia si el orderby es el nativo de búsqueda.
|
|
$ob = $q->get('orderby');
|
|
if (!in_array($ob, ['relevance', 'date', ''], true)) return $clauses;
|
|
|
|
$ft_term = fea_ft_boolean_term($raw);
|
|
if ($ft_term === '') return $clauses;
|
|
|
|
global $wpdb;
|
|
$ft_esc = esc_sql($ft_term);
|
|
|
|
// Añadimos la columna de relevancia al SELECT y la usamos en ORDER BY.
|
|
$score_col = "MATCH({$wpdb->posts}.post_title, {$wpdb->posts}.post_content) AGAINST ('{$ft_esc}' IN BOOLEAN MODE)";
|
|
$clauses['fields'] .= ", ({$score_col}) AS fea_ft_score";
|
|
$clauses['orderby'] = "fea_ft_score DESC, {$wpdb->posts}.post_date DESC";
|
|
|
|
return $clauses;
|
|
}, 10, 2);
|