Descargar el diccionario de la RAE

Author picture
Published on 2023-05-25 00:41:57.643853802 +0000 UTC
Created by gerardooscarjt
developer rae webscrapping
Views 373

Inspirado por este artículo https://duenaslerin.com/diccionario-palabras-espanol-en-texto-script/ que explica cómo hace webscrapping para descargar el diccionario de la Real Academia Española, me he animado a escribir el script en Go para descargar el diccionario más rápido sacando partido de la concurrencia que go trae de serie, o built-in como dirían los ingleses.

Me voy a poner como objetivo descargar únicamente los términos que irán apareciendo por la salida estandar según se vayan recolectando.

Al entrar en la web y buscar una palabra me he dado cuenta que el formulario de búsqueda ¡tiene autocompletado! Esto puede ser interesante...

Autocompletado de términos del diccionario
Autocompletado de términos del diccionario

Me apresuro a abrir el inspector de objetos y teclear algo en el buscador y... ¡aquí está! tenemos una ruta (endpoint o path para los más anglófilos) que nos devuelve las primeras palabras que coinciden con la búsqueda. Tiene todo el sentido devolver sólo unas pocas coincidencias, si viniesen todas el script sería demasiado sencillo ¿Por qué diez? Probablemente a los desarrolladores les pareció bien ese número, tenemos diez dedos en las manos, sistema de numeración base diez y queda un número bien redondo.

Detalle del endpoint de autocompletado
Detalle del endpoint de autocompletado

Este es el endpoint mágico: https://dle.rae.es/srv/keys?q=al.

Este descubrimiento nos ahorrará un buen trabajo ya que no sólo evitará tener que parsear el código html en busca de la información que estamos buscando, sino que viene serializado en formato JSON que es mucho más robusto y estable en el tiempo que el HTML.

El algoritmo

Viendo que el servicio sólo nos devuelve 10 coincidencias, hay que pensar en una estrategia para descargar todos los términos, asegurándonos que no saltamos ninguno, y que lo hacemos en el menor número posible de peticiones.

Acabo de darme cuenta que es sensible a las tildes, con "al" y con "ál" devuelve dos listados diferentes, como se aprecia en la siguiente captura.

Es sensible a tildes
Es sensible a tildes

Una primera idea para el algoritmo es ir construyendo prefijos a partir de una lista de letras (incluyendo las vocales acentuadas). Si la lista que se devuelve tiene menos de 10 elementos, bajo ese prefijo ya no vamos a obtener nuevas palabras. Por ejemplo, con el prefijo "helicó" solamente nos devuelve 3 palabras:

Resultado con menos de diez palabras
Resultado con menos de diez palabras

Sin embargo con el prefijo "arbol" nos devuelve las 10 palabras: 

Resultado con 10 palabras
Resultado con 10 palabras

así que tendremos que probar añadiendo al final cada una de las letras de nuestra lista de la siguiente manera:

arbola arbolb arbolc arbold ... arbolz

Sin olvidar añadir también las vocales acentuadas.

La codificación

Si estuviésemos descargando el diccionario de un idioma sin tildes ni acentos, todo el alfabeto sería representable por el código ASCII y prácticamente podríamos disfrutar de una vida ajena a problemas básicos de codificación. Pero en el español con nuestras vocales acentuadas, tanto en mayúscula como en minúscula y un par de eñes que tenemos, el ASCII se nos queda algo corto y tenemos que tirar de Unicode.

Por suerte contamos con la codificación UTF-8 para representar cualquier tipo de símbolo con múltiples bytes y además distintos sistemas de escapado de caracteres según vayamos a enviar la información por un sitio o por otro.

Para enviar la información, los prefijos se envían en la propia URL, por ejemplo para el término "ánodo" tenemos la siguiente url:

https://dle.rae.es/srv/keys?q=%C3%A1nodo

Para enviar la información tendremos escapar los caracteres con la función encodeURIComponent (en JavaScript) o url.QueryEscape (en Go).

A la hora de recibir la información no deberíamos tener problemas, ya que JSON es capaz de trabajar con ASCII y con UTF-8 perfectamente.

El proyecto

Vamos a crear un proyecto de Go con una estructura de archivos (o layout para los anglofriendlies) muy plana que iremos troceando y agrupando al gusto según crezca nuestro código.

Pero antes de empezar hay que elegir un nombre, un buen nombre estratosférico que sólo con un primer vistazo rápido se nos grabe a fuego en la primera entrada caché de nuestra consciencia y además nos genere una primera idea correcta de lo que hace realmente. Poner nombres no es lo que mejor sé hacer y cuanto más los pienso peor quedan, me voy a quedar con el primero que me ha venido a la mente: raedl

Corto a lo UNIX, difícil de leer, más aún de recordar y teclear sin mirar, a duras penas se puede intuir lo que en realidad hace. Ahora que lo he vuelto a leer por segunda vez me parece más una marca de cerveza.

¿Estás esperando una explicación? Si de verdad necesita explicación, definitivamente no es un buen nombre. Las tres primeras letras "rae" significan "rae", eso se podría llegar a intuir. Las dos últimas "dl" vienen de "download" que viene a decir que te lo downloadeas a tu disco duro. Esto es copiada del comando youtube-dl y de la web de descargas del propio Go (https://go.dev/dl/) que acaba en "dl". Acabo de darme cuenta que han cambiado el dominio a "go.dev", antes era "golang.org".

Bueno, teniendo ya el nombre todo es mucho más fácil, se crea un directorio, inicializamos un repo de git y a empezar!

Arrancando el proyecto
Arrancando el proyecto

Un pequeño Hello World para el hombre...

El <i>Hello World</i> típico en golang
El Hello World típico en golang

Probamos si está todo medio bien configurado:

Compila y no explota
Compila y no explota

Compila y hasta se ejecuta. Perfecto. En este punto podemos asegurar que es el momento con menos bugs de todo el proyecto.

Primera invocación

Un buen punto por donde empezar es implementar una función que pueda llamar al endpoint de autocompletado con un prefijo y nos devuelva la lista de palabras correctamente. Podemos probar con alguna palabra que tenga tilde para estar más seguros de que la codificación está funcionando.

Llamada al endpoint
Llamada al endpoint

Probamos si funciona y....

El primer pete
El primer pete

Vaya, parece que a nosotros no nos devuelve JSON, tiene más pinta de algún HTML de error... Podrían ser varias cosas, puede que necesite una cabecera HTTP indicando que esperamos contenido en formato application/json pero no funcionaría en el navegador web y sí que está funcionando:

En el chrome funciona
En el chrome funciona

Así que probablemente esté esperando otra cabecera HTTP con un User-Agent propio de un navegador. Vamos a probar...

Primera piedra en el camino

Primera piedra en el camino, sigue sin funcionar, y viendo toda la respuesta me encuentro con esta maravilla:

<h2 data-translate="why_captcha_headline">Why do I have to complete a CAPTCHA?</h2> <p data-translate="why_captcha_detail">Completing the CAPTCHA proves you are a human and gives you temporary access to the web property.</p> </div> <div class="cf-column"> <h2 data-translate="resolve_captcha_headline">What can I do to prevent this in the future?</h2>

Han puesto un captcha en la web, y se dispara con alguna condición que todavía no sé cuál es. No tiene buena pinta porque he visto las cabeceras de cloudflare por ahí y suelen hacer las cosas bien pero tiene que haber una manera, porque en el navegador en modo incógnito funciona sin captcha.

Tengo que agradecer a estos individuos: https://github.com/DaRealFreak/cloudflare-bp-go por darme la pista que hace que cloudflare me plante un puto captcha en la cara.

Es una combinación de user agent + la preferencia de algoritmos de curva eliptica para el cifrado tls. ¡Brutal! ¿Quién iba a pensar que CloudFlare toma una decisión en función de la preferencia de los certificados TLS?

Sorteando Cloud Flare

Ahora no tenemos piedras, ni camino, sino una autovía de siete carriles en un día soleado bajando un puerto de montaña. Puede que sin frenos.

Para que cloudflare no nos descubra le enviamos un User-Agent de un navegador corriente. Una práctica bastante habitual pero poco o nada efectiva ya que el destino debe creer la información que le enviamos, no tiene una forma de validar nuestro cliente.

El segundo filtro es digno de película de James Bond o incluso de La Casa de Papel. Se trata de aprovechar pequeños detalles al establecer la conexión cifrada con el protocolo TLS. Para comenzar la conexión, entran en una fase de negociación (técnicamente conocida como handshake) en la que acuerdan el algoritmo de cifrado (cyphersuite) que utilizarán. Tanto cliente como servidor dispone de su propia lista de algoritmos, cada uno con su propia preferencia. Pues bien, nuestros amigos de cloud flare se fijan en el orden de las cypersuite que recibe para descartar posibles clientes que se hacen pasar por navegadores.

Esta es la preferencia de cyphersuites que Golang utiliza por defecto:

[]CurveID{X25519, CurveP256, CurveP384, CurveP521}

Y esta la que nos hace pasar desapercibidos ante el filtro de Cloud Flare:

[]tls.CurveID{CurveP256, CurveP384, CurveP521, X25519}

Más info al respecto:

Sorteando Cloud Flare
Sorteando Cloud Flare

Pero no cantemos victoria. Todavía hay muchos mecanismos que están acechando para hacernos caer por el acantilado del baneo.

Para desarrollar nuestro algoritmo sin necesidad de invocar realmente a la RAE e intentar descargar el diccionario completo en cada prueba, vamos a crear una función mock que simula el comportamiento del servicio con un set de datos controlado.

Mockeando la RAE

La implementación de este mock es tan simple como la idea, se trata de devolver una lista de palabras concreta para cierto prefijo.

var mockData = map[string][]string{ "a": {"a", "a-", "aba", "abaá", "ababillarse", "ababol", "abacá", "abacal", "abacalera", "abacalero"}, "ab": {"aba", "abaá", "ababillarse", "ababol", "abacá", "abacal", "abacalera", "abacalero", "abacera", "abacería"}, "aba": {"aba", "abaá", "ababillarse", "ababol", "abacá", "abacal", "abacalera", "abacalero", "abacera", "abacería"}, "abab": {"ababillarse", "ababol"}, } func fetchWordsMock(prefix string) ([]string, error) { result, ok := mockData[prefix] if ok { return result, nil } return []string{}, nil }

Ahora tenemos un sitio donde elegimos qué función hará el fetch:

var fetch = fetchWordsMock

El algoritmo

Empecemos por lo que sabemos seguro, si un prefijo nos devuelve menos de 10 términos, toda esa lista son palabras nuevas, así que las imprimimos tal cual.

Por ejemplo, para el prefijo zarzap tenemos la siguiente lista:

["zarzaparrilla","zarzaparrillar","zarzaperruna"]

También sabemos que si el prefijo que probamos nos devuelve exactamente 10 términos, podría haber más palabras, así que descartamos el resultado y probamos el mismo prefijo con cada una de las letras del alfabeto.

Pero prestemos atención a un pequeño detalle en este caso. Si en la lista hay palabras de menor longitud que el próximo prefijo, éstas no volverán a aparecer ya que los siguientes prefijos no podrán devolver palabras de longitud menor. Por ejemplo con el prefijo a tendríamos las siguientes palabras:

["a", "a-", "aba", "abaá", "ababillarse", "ababol", "abacá", "abacal", "abacalera", "abacalero"]

Si descartamos esta lista de palabras completa en busca de más listas producidas a partir de prefijos más largos, la palabra a nunca volverá a aparecer.

Pensando más detenidamente, la única palabra que no se podría recuperar de esta forma es el propio prefijo, por lo tanto solamente hay que comprobar si el prefijo es una palabra de verdad mirando en la lista antes de descartarla por completo.

¿Por qué descartamos la lista si ya tiene palabras nuevas? Principalmente porque acabaremos muy probablemente con palabras duplicadas, ya que distintos prefijos pueden devolver las mismas palabras.

Controlar los duplicados tiene múltiples problemas ya que para asegurar que no repetimos palabras tendríamos que guardarlas en algún sitio y comprobar cada palabra que recibamos antes de imprimirla, lo que incurriría en un gasto de memoria extra (tendríamos que guardar todo el diccionario en memoria). Actualmente almacenar una lista de términos en memoria es trivial, prácticamente cualquier dispositivo tiene capacidad más que de sobra para ello pero ¿por qué malgastar un recurso pudiendo no hacerlo?

Además habría que invertir tiempo de CPU en comprobar cada nuevo término que queramos incorporar a nuestro cada vez más pesado diccionario, algo que se resuelve cómoda y eficientemente con un hashmap pero ahí no acaba el problema, ya que si queremos procesar en paralelo hay que proteger el mapa ante accesos concurrentes o podríamos acabar teniendo duplicados.

Para terminar, la forma que tenemos de recorrer los prefijos es lo que se conoce como recorrido en profundidad, que nos devolvería los términos correctamente ordenados pero que perderíamos si utilizamos un hashmap (que no garantiza el orden) lo que nos supondría un coste extra de tiempo en ordenar todos los términos.

Detengámonos brevemente para observar el reducido número de instrucciones que implementan tanta palabrería:

Función de búsqueda
Función de búsqueda

Fuego real

Vamos allá, cambiamos el mock y ponemos la función que descarga de la RAE y...

$ go run main.go a aba abaá ababillarse ababol abacal abacalera abacalero abacá abacera abacería abacero abacial abacora abacorar ...

Funciona! (ya lo sabíamos, teníamos el mock) pero funciona de una forma muy lenta. El principal motivo es que

estamos creando un cliente nuevo con su capa de transporte nueva cada vez, lo que fuerza establecer una conexión nueva en cada petición, que es algo costoso.

Reutilizando conexiones

Vamos a mejorar eso creando un único cliente para reutilizar las conexiones TCP, pero vamos a hacerlo sacándole partido a las closures que soporta el lenguaje. Así en este caso nos ahorramos crear un struct de golang (más o menos equivalente a una clase en programación orientada a objetos).

Una closure es básicamente una función externa que tiene dentro otra función definida (llamémosle función interna) que tiene acceso a todo lo que hay en el ámbito (o scope) de la función externa, es decir, se puede acceder a todas las variables, identificadores, etc.

Se utiliza en programación funcional para implementar encapsulación y generadores.

Esta es la idea:

func extern() func() string { secret := "my secret" return func() string { return secret } }

Este el resultado:

func NewFetchWordsRAE() func(prefix string) ([]string, error) { httpClient := &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{ CurvePreferences: []tls.CurveID{tls.CurveP256, tls.CurveP384, tls.CurveP521, tls.X25519}, }, }, } return func(prefix string) ([]string, error) { escapedTerm := url.QueryEscape(prefix) req, err := http.NewRequest("GET", "https://dle.rae.es/srv/keys?q="+escapedTerm, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:98.0) Gecko/20100101 Firefox/98.0") resp, err := httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() result := []string{} err = json.NewDecoder(resp.Body).Decode(&result) if err != nil { return nil, err } io.Copy(ioutil.Discard, resp.Body) // To consume possible trailing characters after the JSON ends return result, nil } }

Y lo utilizaríamos así:

var fetch = NewFetchWordsRAE()

Ahora en 10 segundos ha reutilizado 189 veces la conexión y ha descargado 77 términos. Sin reutilizar conexiones sólo ha descargado 47 términos. Aproximadamente el 40% del tiempo se ha consumido abriendo y cerrando conexiones TCP. Esto tiene otra desventaja, ya que podríamos quedarnos con todos los puertos que quieran conectar a la dirección del servicio en estado WAIT sin posibilidad de conectar hasta pasados un par de minutos.

Un cálculo de servilleta

Con estos datos y sabiendo que la RAE tiene aproximadamente 200.000 términos (según la propia RAE) podemos estimar cuánto tardaríamos en descargarlos todos. A un ritmo de 7.7 términos por segundo, tardamos unos 26000 segundos, unas 7 horas y media.

No es tanto como parece, se puede dejar una noche y tendríamos el diccionario sin problema, pero ¿Y si tuviésemos 10 ordenadores? ¿Y qué tal con 500 ordenadores? Con 500 ordenadores tardaríamos menos de un minuto. Suena tentador pero muy probablemente Cloud Flare nos bloquee temporalmente al pensar que es un intento de ataque o que pase a un bloqueo más permanente basado en algún patrón que sus algoritmos de machine learning encuentren.

Con procesos largos (vamos a hacerles unas cuantas peticiones) es probable que alguna de ellas falle (por bloqueos o por cualquier otro motivo) y deberíamos añadir un sistema de reintentos para esos casos. Incluso podría ser suficiente si el bloqueo dura poco tiempo.

¿Lo intentamos?

Reto: Descargar la RAE en un minuto

Obviamente no disponemos de 500 ordenadores, ni 100 pero podemos utilizar los mecanismos de concurrencia y sincronización de Golang para lanzar simultáneamente varias peticiones. Eso sí, el orden de términos lo perderemos si no hacemos un sistema que reordene los resultados según van llegando.

Vamos a utilizar un canal en el que ir encolando las tareas (que son los prefijos que vamos a descargar) y de ese canal estarán leyendo varios consumidores (aka workers). El patrón queda sencillo pero ahora mismo no se me ocurre una condición de parada... ya pensaremos algo más adelante.

Acabo de ejecutarlo 10 segundos con solamente 2 consumidores y se ha descargado la friolera de 231 palabras. Probablemente por la forma de recorrer los términos hayamos encontrado antes resultados con menos de 10 elementos que son los que utilizamos para recolectar palabras.

Voy a dejarlo 5 minutos con 100 workers por si suena la flauta...

403 Forbidden map[Alt-Svc:[h3=":443"; ma=86400, h3-29=":443"; ma=86400] Cache-Control:[private, max-age=0, no-store, no-cache, must-revalidate, post-check=0, pre-check=0] Cf-Chl-Bypass:[1] Cf-Ray:[6f457b5218a9737d-MRS] Content-Type:[text/html; charset=UTF-8] Date:[Thu, 31 Mar 2022 01:58:44 GMT] Expect-Ct:[max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct"] Expires:[Thu, 01 Jan 1970 00:00:01 GMT] Nel:[{"success_fraction":0,"report_to":"cf-nel","max_age":604800}] Permissions-Policy:[accelerometer=(),autoplay=(),camera=(),clipboard-read=(),clipboard-write=(),fullscreen=(),geolocation=(),gyroscope=(),hid=(),interest-cohort=(),magnetometer=(),microphone=(),payment=(),publickey-credentials-get=(),screen-wake-lock=(),serial=(),sync-xhr=(),usb=()] Report-To:[{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=qsHmpHPFStT6INl9AuYrGIzwxm3wTp2AERBmNgYgUYdVW2XoUUpNgKEiG2%2F7XSaAYgv3Ha9n4eNDFNzhVn89bK5iZ%2BYSBH22hQ7C4C5%2FEJjxWt4H9ocp%2F79IfYs%3D"}],"group":"cf-nel","max_age":604800}] Server:[cloudflare] Vary:[Accept-Encoding] X-Frame-Options:[SAMEORIGIN]] ERROR invalid character '<' looking for beginning of value

Vaya, se han dado cuenta. Otra vez será.

El fuente

Aquí están todas las partes juntas funcionando:

https://gist.github.com/fulldump/80f4a2229d0b45006880e6586feb5cff

A día de hoy, todavía funciona :crossfingers:

$ go run raedl.go > rae.txt pending tasks: 25 requests: 0 pending tasks: 3331 requests: 190 pending tasks: 6207 requests: 416 pending tasks: 9149 requests: 642 pending tasks: 11846 requests: 882 pending tasks: 13348 requests: 1096 pending tasks: 15487 requests: 1300 pending tasks: 17010 requests: 1493 pending tasks: 19443 requests: 1700 pending tasks: 21383 requests: 1905

Espero que hayas disfrutado de este articulo.