Word Pattern Finder (Spanish)
I have created a pattern-based word search engine. Although it sounds very technical, it is actually a great tool for solving word games like Scrabble, writing poetry (finding rhymes), or simply satisfying your lexicographical curiosity.
Enter at least two letters; you can use wildcards to find matching words.
1. How to use it? Quick wildcard guide
Enter a pattern using wildcards, and the search engine will return all the matching words from the Spanish dictionary (over 80,000).
The search engine uses the word database I extracted for my password generator, so it includes verb conjugations, plurals, and words with accent marks. Furthermore, the search is case-insensitive and ignores accent marks when processing text (if you search for arbol, it will find árbol).
1.1. Quick wildcard guide
*(Asterisk): Represents any number of letters (including none).?(Question mark): Represents exactly one letter of any kind.[...](Brackets): Used to specify several valid options for a single letter.[^...](Brackets with caret): Represents negation. Excludes the indicated letters for that position.
1.2. Practical examples
For crosswords (you know the length and some letters):
c?s?➡️ Finds 4-letter words starting with C, followed by any letter, an S, and another random letter (casa, cosa, caso, casi…).p?l?t?➡️ 6-letter words (pelota, paleta, piloto…).
For rhymes or suffixes (you know how it ends):
*mente➡️ All words ending in “-mente” (fácilmente, rápidamente…).*ción➡️ Words ending in “-ción” (canción, emoción…).
For prefixes (you know how it begins):
des*➡️ All words starting with “des-”.anti*➡️ Words starting with “anti-”.
Advanced combinations (ideal for Wordle):
[mp]a*➡️ Words starting with M or P, followed by an A and then any ending (madre, padre, mapa…).c[^a]sa➡️ Four-letter words starting with C, ending in SA, but whose second letter is NOT an A (cesa, cosa…).c[ao]s[ao]➡️ Will only find exact variations (casa, caso, cosa, coso).
Note
- Only full words are searched.
- Spaces, numbers, or punctuation marks are not allowed.
- It is designed for Spanish, but the code works for other languages.
2. How to install it
You can use this website without needing to install it. If you want to install it, here are the instructions to do so on Debian 13 (Trixie).
2.1. Install Nginx and PHP
The first step is to install the Nginx server, PHP, and its sqlite3 extension:
$ sudo apt install nginx php8.4-fpm php8.4-sqlite3 php8.4-mbstring
Activate the Nginx and PHP server:
$ sudo systemctl restart php8.4-fpm nginx
Copy all files to the server:
$ cp * /var/www/html
2.2. Get and prepare the word list
Now we need to get the word list from the diccionario-espanol-txt repository, which Mr. Jorge Dueñas Lerín has generously shared with everyone:
$ wget [https://raw.githubusercontent.com/JorgeDuenasLerin/diccionario-espanol-txt/refs/heads/master/data/archive/2024-05-22/0_palabras_todas.txt](https://raw.githubusercontent.com/JorgeDuenasLerin/diccionario-espanol-txt/refs/heads/master/data/archive/2024-05-22/0_palabras_todas.txt)
Remove invalid characters (everything except letters) leftover from the downloaded file 0_palabras_todas.txt:
$ sed -E '
s/[^A-Za-zÁÉÍÓÚÜÑáéíóúüñ -]//g
/[A-Za-zÁÉÍÓÚÜÑáéíóúüñ]/!d
/--/d
' 0_palabras_todas.txt > palabras-limpias.txt
To create the palabras.sqlite3 database and insert the words from the palabras-limpias.txt file, we can use this Python script, which we can call importar-palabras.py:
#!/usr/bin/env python3
import sqlite3
import os
import unicodedata
# File names
DB_NAME = 'palabras.sqlite3'
TXT_NAME = 'palabras-limpias.txt'
def get_sort_key(word):
"""
Creates a sorting key for the Spanish alphabet:
1. Converts to lowercase.
2. Removes accents (á->a, é->e...).
3. Treats 'ñ' as 'n{' so it goes after 'n'.
"""
w = word.lower()
# Normalize Unicode to separate accents from letters (NFKD)
norm = unicodedata.normalize('NFKD', w)
# Remove combining characters (accents)
base = "".join([c for c in norm if not unicodedata.combining(c)])
# 'ñ' becomes 'n{'. '{' has a higher ASCII value than 'z',
# so 'ñ' is sorted after any word starting with 'n'.
return base.replace('ñ', 'n{')
def importar():
conn = sqlite3.connect(DB_NAME)
cursor = conn.cursor()
# Speed configuration for insertion
cursor.executescript('PRAGMA journal_mode = OFF; PRAGMA synchronous = 0;')
# Create table with sorting key (sort_key)
# form is the PRIMARY KEY, which guarantees uniqueness.
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. Read and remove duplicates using 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: File {TXT_NAME} not found")
return
# 2. Prepare final data with the calculated sort_key
datos_finales = []
for p in conjunto_palabras:
datos_finales.append((p, p.lower(), get_sort_key(p), len(p)))
# 3. Bulk insert
cursor.executemany('INSERT INTO forms VALUES (?, ?, ?, ?)', datos_finales)
# 4. Create indexes to optimize queries (especially the sort_key one)
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()
# Lock file to read-only
os.chmod(DB_NAME, 0o444)
print(f"Database generated with {len(datos_finales)} unique words.")
if __name__ == "__main__":
importar()
Import the words:
$ python3 importar-palabras.py
2.3. Rest of the files
Download the necessary files for the pattern search engine: buscador-patrones.zip. This file contains all the necessary files except the word database, which is generated following the instructions in the previous section. All files must be pasted into /var/www/html/ including the palabras.sqlite3 database. The /var/www/html/ folder should contain these files:
patrones.htmlpalabras.sqlite3pattern.jspattern-worker.jspattern.php
You can access it with your browser at http://localhost/patrones.html.
You can also copy and paste them, but do not change their names (it will not work if you do):
The main web file patrones.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pattern Search Engine</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; /* Hidden if there are no results */
}
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>Word Pattern Search Engine</h1>
<input type="text" id="pattern-input" placeholder="E.g.: ardor*, *mente, z?pa..." autocomplete="off">
<div id="pattern-note" class="status-msg"></div>
<ul id="pattern-list"></ul>
</main>
</body>
</html>
Main JavaScript file pattern.js:
document.addEventListener('DOMContentLoaded', () => {
const patternInput = document.getElementById("pattern-input");
const statusInfo = document.getElementById("pattern-note");
const list = document.getElementById("pattern-list");
// Relative paths to work in any folder
const worker = new Worker('pattern-worker.js');
const apiUrl = "pattern.php?p=";
// Integrated Debounce function (no need for 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 = `🔍 Searching for «${pattern}»…`;
// Send to the Worker
worker.postMessage({ url: `${apiUrl}${encodeURIComponent(pattern)}` });
}
// Receive response from the Worker
worker.onmessage = (e) => {
const { success, data, error } = e.data;
if (!success) {
statusInfo.innerText = `❌ Error: ${error}`;
return;
}
if (data.length === 0) {
statusInfo.innerText = "➡️ No words match.";
return;
}
// Success: Show results
statusInfo.innerText = `✅ ${data.length} results found.`;
list.innerHTML = data.map(word => `<li>${word}</li>`).join("");
};
// Listen to input with a 300ms delay
patternInput.addEventListener("input", debounce(getData, 300));
});
The Web Worker file so it doesn’t block the interface pattern-worker.js:
self.onmessage = async (e) => {
// Take the relative path directly
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 || 'Server error' });
} else {
self.postMessage({ success: true, data: data });
}
} catch (err) {
self.postMessage({ success: false, error: err.message });
}
};
On the server, the pattern.php file:
<?php
header('Content-Type: application/json; charset=utf-8');
// If there is no parameter or it is empty, return an empty array
if (!isset($_GET["p"]) \vert{}\vert{} trim($_GET["p"]) === '') {
echo json_encode([]);
exit;
}
// Basic pattern cleaning
$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);
// Subquery to force filtering before applying the 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' => 'Database error']);
}
?>