Descargar el diccionario de la RAE
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...
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.
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.
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:
Sin embargo con el prefijo "arbol" nos devuelve las 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!
Un pequeño Hello World para el hombre...
Probamos si está todo medio bien configurado:
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.
Probamos si funciona y....
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:
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:
- Artículo de CloudFlare Staying on top of TLS attacks
- Ejemplo de Cihphersuites de Chrome 57
- TLS Supported Groups (IANA)
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:
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.