Es una técnica utilizada mediante programas de software para extraer información de sitios web. Usualmente, estos programas simulan la navegación de un humano en la World Wide Web ya sea utilizando el protocolo HTTP manualmente, o incrustando un navegador en una aplicación.

https://es.wikipedia.org/wiki/Web_scraping

Nuestro objetivo será crear una aplicación capaz de detectar y minar información de un sitio web con el fin de conformar una base de datos de la que podamos extraer y analizar información para nuestro beneficio.

En este caso, vamos a obtener una lista de películas notables que se estrenaron desde hace 100 años hasta hoy en día, según Wikipedia (en).

Pero antes de comenzar a derrochar código debemos entender algunas cosas importantes:

  • Vamos a utilizar solo la librería estándar de Go para realizar esta tarea, esto es debido a que vamos a construir nuestra propia aplicación de cero, para entender como funciona cada componente y lo que sucede internamente cuando navegamos un sitio web.
  • Existen muchas formas de lograr el mismo objetivo, unas más efectivas que otras bajo ciertas condiciones, nuestro objetivo no es crear una herramienta que compita con las soluciones más sofisticadas de minería de datos, sino plantear una opción más a la que se puede recurrir.
  • Algunas formas de minería de datos y web scraping pueden ser ilegales debido a la manera en que se extrae la información o el uso que se le de a esta, además de que constantemente se implementan soluciones que evitan la navegación automática de sitios web (como el uso de captcha y otros retos), debemos ser cuidadosos con este tipo de prácticas.
  • Si realizamos muchas peticiones similares desde uno o varios orígenes es probable que el servicio piense que se trata de un ataque y hará lo necesario para tratar de evitarlo, a nadie le gusta que le roben información y menos que a causa de esto se provoque un _DoS _a sus servidores.

Primeros pasos

Para comenzar, debemos conocer a grandes rasgos lo que sucede cuando un usuario ingresa a un sitio web con ayuda de su navegador:

  • El usuario ingresa la URL del sitio en el navegador.
  • El navegador prepara una petición GET a ese nombre de dominio o IP, solicitando la ruta de la página deseada.
  • El servidor recibe la petición y genera la respuesta con los datos correspondientes (si se trata de un error o si es una ruta válida) y los envía como respuesta al cliente.
  • El navegador recibe y evalúa la respuesta, y de ser necesario muestra el resultado en la ventana de este.
  • En caso necesario generará las peticiones restantes para descargar el contenido como imágenes, videos, scripts, y otros recursos que conformen la página.
  • En algunos casos, se establecerán conexiones o cargas adicionales de información conforme se navegue el sitio o sucedan eventos que modifiquen el contenido de este.

Entendido esto, debemos generar un código que establezca una conexión HTTP con el sitio web que queremos explorar y descargue el código HTML que lo conforma:

package main

import (
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"os"
)

// loadPage hace una petición get a una URL y devuelve su contenido com un
// []byte que será `nil` si ocurre un error.
func loadPage(url string) (content []byte, err error) {
	res, err := http.Get(url)
	if err != nil {
		return nil, fmt.Errorf("error obteniendo URL [%v]: %v", url, err)
	}

	content, err = ioutil.ReadAll(res.Body)
	if err != nil {
		return nil, fmt.Errorf("error leyendo el contendio de [%v]: %v", url, err)
	}

	return
}

func main() {
	rootUrl := "https://en.wikipedia.org/wiki/1990_in_film"
	content, err := loadPage(rootUrl)
	if err != nil {
		log.Printf("ERROR - %v\n", err)
		os.Exit(1)
	}

	fmt.Printf("%s\n", content)
}

Limpiando el texto obtenido

Dentro del código HTML resultante, deberemos:

  1. buscar por la etiqueta <h2> que indica el inicio de los estrenos relevantes y limpiar el código anterior a este.
  2. Buscar el siguiente subtítulo (<h2>) y eliminar el código restante a partir de este.
  3. Dentro del texto restante, deberemos detectar todos los elementos de la lista que contengan la etiqueta <i> que indicaría el título de una película.
  4. Deberemos detectar si un título contiene un enlace, en cuyo caso extraeremos y almacenaremos como parte del objeto resultante.

Buscando <h2>

Vamos a buscar las palabras notable y release que se encuentren dentro de una etiqueta <h2> con ayuda de expresiones regulares:

<h2>.*notable.*released.*<\/h2> - https://regex101.com/r/KMnmO8/1

No esperamos encontrar más de un resultado, por lo que cualquier otro valor lo consideraremos una anomalía.

Conservando el contenido a partir del cierre de la etiqueta <h2> detectada, realizaremos una búsqueda de la siguiente etiqueta <h2>, utilizando la expresión siguiente, y eliminaremos el texto a partir de esta.

<h2>.*<\/h2>

Elementos de la lista

Ahora vamos a buscar el inicio de cada <li> que contenga una etiqueta <i> enseguida:

<li><i>.*<\/i> - https://regex101.com/r/Pg9Jbe/1

Buscamos una etiqueta <a> al inicio de la etiqueta <i> de cada resultado, esto nos indicará que se trata de un enlace a la página de la película:

<i><a href=\"\S*\"

Si este fuera el caso, necesitaremos limpiar la url: <i><a href=\" y el último caracter \" para almacenar el resto.

Ahora deberemos extraer el título de la película limpiando el inicio del elemento de la lista, incluso si este contara con un enlace:

<li><i>(<a.*\">)*

De igual manera con el resto del contenido, con la siguiente expresión nos aseguramos de eliminar el resto del contenido:

(<\/*(a|i).*)

Considerando algunas de las siguientes posibilidades:

  • Se trata de un texto simple encerrado entre <i></i>
  • Se trata de un texto seguido de un enlace <i>... <a
  • Se trata del texto de un enlace <a href="">... </a>

Todo en una función:

func findReleases(text string, year int) (movies []Movie, err error) {
	// Dividimos el texto por el subtítulo que contenga los términos `notable` y
	// `release` para limpiar la parte superior
	startPattern := `(?mi)<h2>.*notable.*released.*<\/h2>`
	resStrings := regexp.MustCompile(startPattern).Split(text, -1)

	// En caso de no encontrar coincidencias o encontrar más de 1
	if len(resStrings) <= 1 {
		return nil, nil
	} else if len(resStrings) != 2 {
		return nil, errors.New("Unexpected result")
	}

	// Ahora eliminamos el sobrante, dividiendolo por el siguiente subtítulo
	endPattern := `(?mi)<h2>.*<\/h2>`
	resStrings = regexp.MustCompile(endPattern).Split(resStrings[1], -1)

	// Enseguida localizamos los elementos de la lista y el texto contenido,
	// correspondiente a cada título
	moviesList := resStrings[0]
	//fmt.Printf("%v\n", moviesList)
	itemPattern := `(?mi)<li><i>.*</i>`
	resStrings = regexp.MustCompile(itemPattern).FindAllString(moviesList, -1)

	// Vamos a limpiar cada elemento
	for _, item := range resStrings {
		// Comenzaremos detectando si se trata de un enlace
		movie := Movie{}
		hrefPattern := `(?mi)<i><a href=\"\S*\"`
		hrefRx := regexp.MustCompile(hrefPattern)

		// Si se trata de un enlace, entonces obtenemos el destino y los
		// almacenamos como parte de la película
		if hrefRx.MatchString(item) {
			// Esto nos ayuda a limpiar el inicio de la url
			hrefVal := hrefRx.FindAllString(item, 1)[0]
			hrefPattern := `(?mi)<i><a href=\"`
			replRx := regexp.MustCompile(hrefPattern)
			hrefVal = replRx.ReplaceAllString(hrefVal, "")
			// Y esto, el final
			hrefVal = hrefVal[:len(hrefVal)-1]

			// almacenamos el valor
			movie.Link = hrefVal
		}

		// Ahora obtenemos el nombre de la película
		replPattA := `(?mi)<li><i>(<a.*\">)*`
		replRx := regexp.MustCompile(replPattA)
		strName := replRx.ReplaceAllString(item, "")

		replPattB := `(?mi)(<\/*(a|i).*)`
		replRx = regexp.MustCompile(replPattB)
		strName = replRx.ReplaceAllString(strName, "")

		// Y lo almacenamos
		movie.Title = strName

		movie.Year = year

		// agregamos la película al resultado
		movies = append(movies, movie)
	}

	return
}

Nota: Podemos probar y jugar con expresiones regulares en este sitio que cuenta con muchas herramientas:

https://regex101.com/

Almacenando los datos

Finalmente decidimos almacenar los datos obtenidos en archivos locales en formato JSON agrupados por año, con el fin de consultarlos más adelante y evitar visitar el sitio nuevamente cada vez que se requiera hacer una consulta.

f, err := os.OpenFile(filePath, os.O_CREATE+os.O_WRONLY, os.ModePerm)
if err != nil {
	log.Printf("ERROR - error openning file for write, %v\n", err)
	continue
}

jsonEnc := json.NewEncoder(f)
jsonEnc.SetIndent("", "  ")
err = jsonEnc.Encode(movies)
if err != nil {
	log.Printf("ERROR - error encoding results, %v\n", err)
	continue
}

err = f.Sync()
if err != nil {
	log.Printf("ERROR - error while sync file, %v\n", err)
	continue
}

log.Print("\t - closing file\n")
err = f.Close()
if err != nil {
	log.Printf("ERROR - error closing file, %v\n", err)
	continue
}

Programa completo

Salida

Salida parcial
Salida parcial

Datos recuperados

1960.json
1960.json

Algunas conclusiones

Calidad de los datos

Se observaron algunos títulos con errores de formato que alteran su valor; en este caso se trata de errores al momento de la captura por lo que no teneos muchas opciones más que detectarlos y tratarlos de manera específica hasta satisfacer todos los casos, o tratar de obtener el valor correcto desde la URL particular de cada película, si se contara con esta.

Velocidad de minado

Debido a que se trata de un minado secuencial no podemos procesar más de una URL a la vez, esto limita la velocidad de obtención de datos pero también reduce las conexiones y sesiones activas en el servidor.

Palabras finales

Hemos realizado un scraping sencillo que no ha implicado situaciones más complejas como autenticación o técnicas de evasión; esto nos ayuda a entender el proceso básico de consulta de una URL y la manera en que podemos aprovechar las expresiones regulares para detectar y extraer la información requerida.

No me quiero despedir si recordarte que contamos con un servidor en Discord al que puedes unirte para platicar de estos temas accediendo a la sección comunidad o dando click en el enlace; de igual forma me gustaría saber tu opinión y sugerencias, déjame un comentario más abajo o en cualquiera de mis redes.