Alejandro Muñoz Fernández

Buscador de palabras por patrón

He creado un buscador de palabras basado en patrones. Aunque suene muy técnico, en realidad es una herramienta estupenda para resolver juegos de palabras como el Scrabble, hacer poesía (encontrar rimas) o, simplemente, saciar tu curiosidad lexicográfica.

Mete, al menos, dos letras; puedes usar comodines para encontrar las palabras que coincidan.

    1. ¿Cómo usarlo? Guía rápida de comodines

    Introduce un patrón usando comodines y el buscador te devolverá todas las palabras del diccionario español (más de 80 000) que encajen.

    El buscador utiliza la base de datos de palabras que extraje para mi generador de contraseñas, por lo que incluye conjugaciones verbales, plurales y palabras con tilde. Además, la búsqueda no distingue entre mayúsculas y minúsculas e ignora las tildes al procesar el texto (si buscas arbol, encontrará árbol).

    1.1. Guía rápida de comodines

    1.2. Ejemplos prácticos

    Para crucigramas (conoces la longitud y algunas letras):

    Para rimas o sufijos (conoces cómo termina):

    Para prefijos (conoces cómo empieza):

    Combinaciones avanzadas (ideales para Wordle):

    Nota
    • Solo se buscan palabras completas
    • No se admiten espacios, números ni signos de puntuación
    • Está pensado para español, pero el código funciona para otros idiomas

    2. Cómo instalarlo

    Puedes usar esta web sin necesidad de instalarlo. Si lo quieres instalar, estas son las instrucciones para instalarlo en Debian 13 (Trixie).

    2.1. Instalar nginx y php

    Lo primero es instalar el servidor Nginx, php y su extensión sqlite3

    $ sudo apt install nginx php8.4-fpm php8.4-sqlite3 php8.4-mbstring
    

    Activar el servidor Nginx y PHP:

    $ sudo systemctl restart php8.4-fpm nginx
    

    Copiar todos los archivos al servidor:

    $ cp * /var/www/html
    

    2.2. Obtener y preparar la lista de palabras

    Ahora hay que obtener la lista de palabras del repositorio diccionario-espanol-txt que don Jorge Dueñas Lerín ha compartido con todos:

    $ wget https://raw.githubusercontent.com/JorgeDuenasLerin/diccionario-espanol-txt/refs/heads/master/data/archive/2024-05-22/0_palabras_todas.txt
    

    Quitar caracteres no válidos (todos menos las letras) que sobran del fichero descargado 0_palabras_todas.txt:

    $ sed -E '
    s/[^A-Za-zÁÉÍÓÚÜÑáéíóúüñ -]//g
    /[A-Za-zÁÉÍÓÚÜÑáéíóúüñ]/!d
    /--/d
    ' 0_palabras_todas.txt > palabras-limpias.txt
    

    Para crear la base de datos palabras.sqlite3 y meterle las palabras el archivo palabras-limpias.txt podemos valernos de este script de Python, que podemos llamar importar-palabras.py:

    #!/usr/bin/env python3
    import sqlite3
    import os
    import unicodedata
    
    # Nombre de los archivos
    DB_NAME = 'palabras.sqlite3'
    TXT_NAME = 'palabras-limpias.txt'
    
    def get_sort_key(word):
        """
        Crea una clave de ordenación para el alfabeto español:
        1. Convierte a minúsculas.
        2. Elimina acentos (á->a, é->e...).
        3. Trata la 'ñ' como 'n{' para que vaya después de la 'n'.
        """
        w = word.lower()
        # Normalizar Unicode para separar acentos de letras (NFKD)
        norm = unicodedata.normalize('NFKD', w)
        # Eliminar caracteres combinados (los acentos)
        base = "".join([c for c in norm if not unicodedata.combining(c)])
        # 'ñ' se convierte en 'n{'. '{' tiene un valor ASCII mayor que 'z',
        # por lo que 'ñ' se ordena después de cualquier palabra que empiece por 'n'.
        return base.replace('ñ', 'n{')
    
    def importar():
        conn = sqlite3.connect(DB_NAME)
        cursor = conn.cursor()
        # Configuración de velocidad para la inserción
        cursor.executescript('PRAGMA journal_mode = OFF; PRAGMA synchronous = 0;')
    
        # Crear tabla con clave de ordenación (sort_key)
        # form es el PRIMARY KEY, lo que garantiza unicidad.
        cursor.executescript('''
            DROP TABLE IF EXISTS forms;
            CREATE TABLE forms (
                form TEXT PRIMARY KEY,
                search TEXT NOT NULL,
                sort_key TEXT NOT NULL,
                len INTEGER NOT NULL
            ) WITHOUT ROWID;
        ''')
    
        # 1. Leer y eliminar duplicados mediante set()
        conjunto_palabras = set()
        try:
            with open(TXT_NAME, 'r', encoding='utf-8') as f:
                for linea in f:
                    original = linea.strip()
                    if original:
                        conjunto_palabras.add(original)
        except FileNotFoundError:
            print(f"Error: No se encuentra el archivo {TXT_NAME}")
            return
    
        # 2. Preparar datos finales con la sort_key calculada
        datos_finales = []
        for p in conjunto_palabras:
            datos_finales.append((p, p.lower(), get_sort_key(p), len(p)))
    
        # 3. Insertar en bloque
        cursor.executemany('INSERT INTO forms VALUES (?, ?, ?, ?)', datos_finales)
    
        # 4. Crear índices para optimizar las consultas (especialmente el de sort_key)
        cursor.executescript('''
            CREATE INDEX idx_forms_search ON forms(search);
            CREATE INDEX idx_forms_len ON forms(len);
            CREATE INDEX idx_forms_sort ON forms(sort_key);
            VACUUM;
            ANALYZE;
        ''')
    
        conn.commit()
        conn.close()
    
        # Bloquear archivo a solo lectura
        os.chmod(DB_NAME, 0o444)
        print(f"Base de datos generada con {len(datos_finales)} palabras únicas.")
    
    if __name__ == "__main__":
        importar()
    

    Importar las palabras:

    $ python3 importar-palabras.py
    

    2.3. Resto de ficheros

    Descarga los archivos necesarios del buscador de patrones: buscador-patrones.zip. En ese archivo están todos los ficheros necesarios menos la base de datos de palabras, que se genera siguiendo las instrucciones de la sección anterior. Todos los archivos se pegan en /var/www/html/ incluida la base de datos palabras.sqlite3. La carpeta /var/www/html/ debe contener estos archivos:

    Se accede con el navegador a la dirección http://localhost/patrones.html.

    También puedes copiarlos y pegarlos, pero no le cambies el nombre (no funcionará si lo haces):

    El archivo web principal patrones.html:

    <!DOCTYPE html>
    <html lang="es">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Buscador de Patrones</title>
        <style>
            :root {
                --primary: #2563eb;
                --bg: #f8fafc;
                --text: #1e293b;
                --border: #cbd5e1;
            }
            body {
                font-family: system-ui, -apple-system, sans-serif;
                background-color: var(--bg);
                color: var(--text);
                margin: 0;
                padding: 2rem;
                display: flex;
                justify-content: center;
            }
            .container {
                width: 100%;
                max-width: 600px;
                background: white;
                padding: 2rem;
                border-radius: 8px;
                box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
            }
            h1 { margin-top: 0; font-size: 1.5rem; }
            input {
                width: 100%;
                padding: 0.75rem;
                font-size: 1rem;
                border: 1px solid var(--border);
                border-radius: 4px;
                box-sizing: border-box;
                margin-bottom: 0.5rem;
            }
            input:focus {
                outline: none;
                border-color: var(--primary);
                box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
            }
            .status-msg {
                font-size: 0.875rem;
                color: #64748b;
                min-height: 1.5rem;
                margin-bottom: 1rem;
            }
            ul {
                list-style: none;
                padding: 0;
                margin: 0;
                max-height: 400px;
                overflow-y: auto;
                border: 1px solid var(--border);
                border-radius: 4px;
                display: none; /* Se oculta si no hay resultados */
            }
            ul:not(:empty) { display: block; }
            li {
                padding: 0.5rem 1rem;
                border-bottom: 1px solid var(--border);
            }
            li:last-child { border-bottom: none; }
            li:hover { background-color: #f1f5f9; }
        </style>
        <script defer src="pattern.js"></script>
    </head>
    <body>
        <main class="container">
            <h1>Buscador de patrones de palabras</h1>
            <input type="text" id="pattern-input" placeholder="Ej: ardor*, *mente, z?pa..." autocomplete="off">
            <div id="pattern-note" class="status-msg"></div>
            <ul id="pattern-list"></ul>
        </main>
    </body>
    </html>
    

    Archivo principal de JavaScript pattern.js:

    document.addEventListener('DOMContentLoaded', () => {
        const patternInput = document.getElementById("pattern-input");
        const statusInfo = document.getElementById("pattern-note");
        const list = document.getElementById("pattern-list");
    
        // Rutas relativas para que funcione en cualquier carpeta
        const worker = new Worker('pattern-worker.js');
        const apiUrl = "pattern.php?p=";
    
        // Función Debounce integrada (no necesitas debounce.js)
        function debounce(func, wait) {
            let timeout;
            return function(...args) {
                clearTimeout(timeout);
                timeout = setTimeout(() => func(...args), wait);
            };
        }
    
        function resetUI() {
            list.innerHTML = "";
            statusInfo.innerText = "";
        }
    
        function getData() {
            const pattern = patternInput.value.trim();
    
            if (pattern.length === 0) {
                resetUI();
                return;
            }
    
            resetUI();
            statusInfo.innerText = `🔍 Buscando «${pattern}»…`;
    
            // Enviamos al Worker
            worker.postMessage({ url: `${apiUrl}${encodeURIComponent(pattern)}` });
        }
    
        // Recibimos respuesta del Worker
        worker.onmessage = (e) => {
            const { success, data, error } = e.data;
    
            if (!success) {
                statusInfo.innerText = `❌ Error: ${error}`;
                return;
            }
    
            if (data.length === 0) {
                statusInfo.innerText = "➡️ Ninguna palabra coincide.";
                return;
            }
    
            // Éxito: Mostramos resultados
            statusInfo.innerText = `✅ ${data.length} resultados encontrados.`;
            list.innerHTML = data.map(word => `<li>${word}</li>`).join("");
        };
    
        // Escuchamos el input con un retraso de 300ms
        patternInput.addEventListener("input", debounce(getData, 300));
    });
    

    El archivo Web Worker para que no bloquee la interfaz pattern-worker.js:

    self.onmessage = async (e) => {
        // Tomamos la ruta relativa directamente
        const url = e.data.url;
    
        try {
            const response = await fetch(url);
            const data = await response.json();
    
            if (!response.ok) {
                self.postMessage({ success: false, error: data.error || 'Error del servidor' });
            } else {
                self.postMessage({ success: true, data: data });
            }
        } catch (err) {
            self.postMessage({ success: false, error: err.message });
        }
    };
    

    En el servidor, el arhivo pattern.php:

    
    <?php
    header('Content-Type: application/json; charset=utf-8');
    
    // Si no hay parámetro o está vacío, devolvemos array vacío
    if (!isset($_GET["p"]) || trim($_GET["p"]) === '') {
        echo json_encode([]);
        exit;
    }
    
    // Limpieza básica del patrón
    $pattern = mb_strtolower(str_replace("!", "^", $_GET["p"]), 'UTF-8');
    $clean_pattern = preg_replace("/[^\p{L}\*\?\^\-\[\]]/u", "", $pattern);
    
    if ($clean_pattern === '') {
        echo json_encode([]);
        exit;
    }
    
    $exact_length = (strpos($clean_pattern, '*') === false) ? mb_strlen(preg_replace('/\[[^\]]*\]/u', '.', $clean_pattern), 'UTF-8') : null;
    
    try {
        $database = new SQLite3("palabras.sqlite3", SQLITE3_OPEN_READONLY);
    
        // Subconsulta para forzar el filtrado antes de aplicar el sort_key
        $sql = "SELECT form FROM (
            SELECT form, sort_key
            FROM forms
            WHERE search GLOB :form COLLATE NOCASE
            " . ($exact_length !== null ? "AND len = :len " : "") . "
            )
            ORDER BY sort_key ASC";
    
        $stmt = $database->prepare($sql);
        if ($exact_length !== null) {
            $stmt->bindValue(':len', $exact_length, SQLITE3_INTEGER);
        }
        $stmt->bindValue(':form', $clean_pattern, SQLITE3_TEXT);
    
        $res = $stmt->execute();
        $data = [];
        while ($row = $res->fetchArray(SQLITE3_NUM)) {
            $data[] = $row[0];
        }
    
        echo json_encode($data);
        $database->close();
    } catch (Exception $e) {
        http_response_code(500);
        echo json_encode(['error' => 'Error de base de datos']);
    }
    
    ?>