Desarrollo web scrapers en Python desde varios años. La simplicidad de Python permite realizar prototipos rápidos y sus numerosas bibliotecas son muy útiles para el scraping y el parsing de los resultados (Requests, Beautiful Soup, Scrapy, …). Sin embargo, cuando se empieza a interesarse al desempeño de su scraper, Python tiene limites y Go nos ayuda mucho.

¿Porqué Go?

Cuando se trata de acelerar la recuperación de información desde el web (para scraping HTML, como para fetching de API), dos posibilidades de optimización principales existen:

  • acelerar la recuperación del recurso web (p. ej. descargar la pagina http://example.com/hello.html)
  • acelerar el parsing del información recuperado (p. ej. recuperar todas las url contenidas en hello.html)

Se puede mejorar el desempeño del parsing mejorando su código, utilizando un parser mas rápido como lxml, o le asignando más recursos maquina a nuestro scraper. A pesar de todo, se da cuenta de que a menudo la optimización del parsing es insignificante y que el cuello de botella es el acceso red (es decir el descargamiento de la pagina web).

Pues la solución es paralelizar el descargamiento de las paginas web. ¡Por eso Go está una bueno elección!

La programación concurrente es un ámbito muy complicado y Go puede hacerlo bastante fácil. Go es un lenguaje moderno que fue pensado para la concurrencia desde el principio. Al contrario, Python es un lenguaje más antiguo que, a pesar de numerosos esfuerzos recientes, es más complejo cuando se quiere programar un scraper concurrente.

¡Hay otras ventajas utilizar Go, pero vamos a hablar de esto en otro momento!

Instale Go

Ya realicé un pequeño tuto sobre la instalación de Go en Ubuntu.

Si quiere instalar Go en otra plataforma, puede utilizar la doc oficial.

Un scraper concurrente simple

Nuestro scraper se contenta de abrir una lista de paginas web que se le da primero. Después averigua que obtiene un código HTTP 200 (significa que el servidor retorno la pagina HTML sin error). No hay parsing HTML aquí porque la meta está focalizarse sobre el desempeño del acceso a la red. ¡A usted le toca escribir más!

Código final


/*
Open a series of urls.

Check status code for each url and store urls I could not
open in a dedicated array.
Fetch urls concurrently using goroutines.
*/

package main

import (
    "fmt"
    "net/http"
)

// -------------------------------------

// Custom user agent.
const (
    userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) " +
        "AppleWebKit/537.36 (KHTML, like Gecko) " +
        "Chrome/53.0.2785.143 " +
        "Safari/537.36"
)

// -------------------------------------

// fetchUrl opens a url with GET method and sets a custom user agent.
// If url cannot be opened, then log it to a dedicated channel.
func fetchUrl(url string, chFailedUrls chan string, chIsFinished chan bool) {

    // Open url.
    // Need to use http.Client in order to set a custom user agent:
    client := &http.Client{}
    req, _ := http.NewRequest("GET", url, nil)
    req.Header.Set("User-Agent", userAgent)
    resp, err := client.Do(req)

    // Inform the channel chIsFinished that url fetching is done (no
    // matter whether successful or not). Defer triggers only once
    // we leave fetchUrl():
    defer func() {
        chIsFinished <- true
    }()

    // If url could not be opened, we inform the channel chFailedUrls:
    if err != nil || resp.StatusCode != 200 {
        chFailedUrls <- url
        return
    }

}

func main() {

    // Create a random urls list just as an example:
    urlsList := [10]string{
        "http://example1.com",
        "http://example2.com",
        "http://example3.com",
        "http://example4.com",
        "http://example5.com",
        "http://example10.com",
        "http://example20.com",
        "http://example30.com",
        "http://example40.com",
        "http://example50.com",
    }

    // Create 2 channels, 1 to track urls we could not open
    // and 1 to inform url fetching is done:
    chFailedUrls := make(chan string)
    chIsFinished := make(chan bool)

    // Open all urls concurrently using the 'go' keyword:
    for _, url := range urlsList {
        go fetchUrl(url, chFailedUrls, chIsFinished)
    }

    // Receive messages from every concurrent goroutine. If
    // an url fails, we log it to failedUrls array:
    failedUrls := make([]string, 0)
    for i := 0; i < len(urlsList); {
        select {
        case url := <-chFailedUrls:
            failedUrls = append(failedUrls, url)
        case <-chIsFinished:
            i++
        }
    }

    // Print all urls we could not open:
    fmt.Println("Could not fetch these urls: ", failedUrls)

}


Explicaciones

Este código está un poco más largo que lo que podría hacer con un lenguaje como Python, pero es muy razonable. Go es un lenguaje estático, pues declarar las variables toma un poquito más tiempo. ¡Pero mida el tiempo de ejecución de este programa, y va a ver la recompensa!

Tomamos 10 url al azar para el ejemplo.

Aquí las palabras claves están go, chan, y select:

  • go permite crear una nueva goroutine, es decir que fetchUrl sera ejecutado cada vez en una nueva goroutine concurrente.
  • chan es el tipo que representa un channel. Se utiliza los channels para comunicar entre goroutines (main también es una goroutine).
  • select ... case es un switch ... case dedicado a recibir los mensajes enviados por los channels. El programa continua solo cuando todas las goroutines han enviado un mensaje (sea para decir que el fetching de la url esta terminado, o sea para decir que el fetching fracasó).

Se habría podido crear ningún channel para este scraper, es decir solo crear goroutines y nos esperar información a cambio (por ejemplo si cada goroutine termina almacenando el resultado en base de datos). En este caso es posible que nuestra goroutine principal se termine mientras que otras goroutines todavía trabajan (no es necesariamente un problema ya que Go ejecuta todas la goroutines enteramente incluso si la main paró). Pero en realidad, es casi siempre necesario utilizar los channels para hacer comunicar nuestras goroutines.

¡Piense en limitar la velocidad

Aquí la velocidad máximum es lo que se busca, en particular porque se hace scraping de urls todas diferentes. Sin embargo, si necesita descargar varias veces la misma url (en el caso del fetching de una API externa por ejemplo), tendrá que no superar un numero máximum de consultas concurrentes por segundo. En este caso se necesita implementar un contador (¡quizás en un próximo articulo!).

¡Enjoy el scraping!

Also available in English | Existe aussi en français

Rate limiting de la API con Traefik, Docker, Go y Caching

Limitar el uso de la API basándose en una regla avanzada de limitación de velocidad no es tan fácil. Para lograr esto detrás de la API de NLP Cloud, estamos utilizando una combinación de Docker, Traefik (como proxy inverso) y el almacenamiento en caché local dentro de un script Go. Cuando se hace correctamente, se puede mejorar considerablemente el rendimiento de la limitación de la tasa y estrangular adecuadamente las solicitudes de la API sin sacrificar la velocidad de las solicitudes. Seguir leyendo