Haciendo scraping en paralelo

Todo el que haya hecho un poco de Web scraping en algunos lenguajes como Python, se habrá topado con un problema muy habitual: las peticiones de red bloqueantes.

Aunque el scraper tarde unos pocos milisegundos en procesar el HTML, tener que descargarse 10 páginas de forma secuencial hace que el tiempo de ejecución crezca considerablemente. Para sortear este problema, hay muchas alternativas. Veamos algunas de ellas con un ejemplo.

Supongamos que queremos descargarnos los primeros 1000 cómics de XKCD. Un script sencillo sería el siguiente:

1 from requests import get
2 
3 URL = "https://xkcd.com/%s/"
4 
5 pages = []
6 for n in range(1, 1001):
7 	print("Downloading page %s" % n)
8 	pages.append(get(URL % n))

El módulo threading

Con este módulo podemos crear varias threads, y hacer muchas peticiones de forma simultánea:

1 from requests import get
2 from threading import Thread
3 
4 URL = "https://xkcd.com/%s/"
5 pages = []
6 
7 def down(n):
8 	print("Downloading page %s" % n)
9 	pages.append(get(URL % n))
10 
11 threads = [Thread(target = down, args = (n,)) for n in range(1, 1001)]
12 [t.start() for t in threads] # start all threads
13 [t.join() for t in threads] # block until all threads finish

Esto acelera considerablemente el programa, que pasa de tardar unos 17 minutos en mi ordenador, a unos aceptables 17 segundos. Pero el problema sigue ahí: si en lugar de 1000 páginas fueran 10000, ¿sería el procesador capaz de manejar tantísimos hilos de forma óptima? ¿daría con un número máximo de threads?.

El módulo threading con workers

Una alternativa es la creación de workers: un número limitado de threads que no descargan una única página, sino que van descargando páginas hasta obtenerlas todas.

1 from requests import get
2 from threading import Thread
3 
4 URL = "https://xkcd.com/%s/"
5 WORKERS = 20
6 pages = []
7 
8 to_download = [URL % n for n in range(1, 1001)]
9 
10 def worker():
11 	while len(to_download):
12 		url = to_download.pop()
13 		print("Downloading page %s" % url)
14 		pages.append(get(url))
15 
16 workers = [Thread(target = worker) for _ in range(WORKERS)]
17 [w.start() for w in workers] # start all workers
18 [w.join() for w in workers] # block until all workers finish

En mi ordenador, tarda unos 21 segundos en hacer la descarga completa, pero la carga del procesador es mucho menor en este caso. Volvemos a la limitación anterior, ante un gran número de páginas a descargar, se vuelve bastante lento. Pero aun nos queda una opción.

El módulo grequests

Este módulo tiene la misma interfaz que requests, con la diferencia de que al importarlo hay que poner una G por delante. Su instalación desde pip es muy sencilla, y permite hacer las peticiones de forma asíncrona mediante la librería Gevent. Al hacer una petición de varias páginas, grequests crea y gestiona las corrutinas para descargarlas.

1 from grequests import get, map
2 
3 URL = "https://xkcd.com/%s/"
4 
5 reqs = [get(URL % n) for n in range(1, 1001)]
6 print("Downloading all pages")
7 print(map(reqs))

Este código es mucho más sencillo e intuitivo que los anteriores. Además, descarga las 1000 páginas en apenas 15 segundos sin sobrecargar el procesador en absoluto.

Cómic de XKCD

Existen otras alternativas que aun no he explorado, como requests-threads y requests-futures. Si conoces más sobre este tema, no dudes en dejar un comentario!

Sé el primero en comentar!