perri.to: A mashup of things

Scrapeando informacion de una web

  2019-09-29


La idea detras de este tutorial es mas que nada un poco de práctica, intentaremos resolver un problema común sin mucho esfuerzo pero en la realidad no tiene gran utilidad práctica mas allá de la práctica en si.

El ejercicio planteado intentará obtener la cotización del Dolar Estado-Unidense con respecto al Peso Argentino, al menos al día de la fecha no hay a simple vista una API del Banco de la Nacion Argentina para obtener estos datos, así que los vamos a leer desde su sitio web directamente.

Para lograr esto vamos a necesitar dos partes:

La petición

Para el caso de GET que es relativamente simple (no debemos enviar un cuerpo es simplemente acceder a una URL y esperar una respuesta) podemos utilizar directamente un método provisto por la libreria net/http: http.Get que toma como parámetro único la URL, entonces un ejemplo del código sería.

	const bnaURL = "http://www.bna.com.ar/Personas"
	/* ... */
	res, err := http.Get(bnaURL)
	if err != nil {
		log.Fatalf("getting bna website: %v", err)
	}
	/* ... */

Ahora bién, de esta interacción obtenemos res que es un objeto http.Response que contiene un par de miembros que nos importan:

  • StatusCode un entero representando el código de estado HTTP que nos va a servir para saber si el pedido fue en efecto exitoso, esto puede ser engañoso ya que intuitivamente uno esperaría obtener eta información de err pero esto solo nos da el resultado de realizar la petición (errores obtenidos aquí seran por ejemplo cuando hay problemas de red o no se puede resolver la dirección) este miembro en vez contiene el etado de la petición en si.
  • Body esto es un io.ReaderCloser que exporta la interface de un io.Reader y un ‘io.Closer’ y nos permite tratar a Body como si fuese una especie de archivo del cual podemos leer y nos agrega la potestad de cerrarlo para finalizar la petición corretamente.

Nuestro siguiente paso será leer el Body que nos deveolvió la petición (asumiendo que fue exitoso y obtuvimos 200 en StatusCode) e interpretar el HTML para obtener las cotizaciones y a su vez interpretarlas.

Una buena práctica es asegurarse que Body se cerrará, para lo cual utilizaremos un defer que nos permitirá encolar la invocación a Close() sin importar el resultado (en rasgos generales, si la función retorna la invocación se ejecutará, en nuestro ejemplo utilizamos log.Fatalf por brevedad en los ejemplos, la función donde originalmente escribí esto retorna).

En el ejemplo a continuación ademas veremos que se utiliza Response.Status que es un texto del estado de retorno (por ejemplo 200 OK).

	defer res.Body.Close()
	if res.StatusCode != 200 {
		log.Fatalf("código de estado de la petición inesperado: %d %s", res.StatusCode, res.Status)
	}

El código HTML

Habiendonos ocupado de lo referente a control de errores, podemos ahora dedicarnos a interpretar el HTML y obtener las cotizaciones, para ello utilizaremos la librería goquery que nos permite seleccionar nodos del DOM HTML como si fuese jQuery

Lo primero que haremos es instanciar lo que goquery llama un Document utilizando el constructor goquery.NewDocumentFromReader que podrá tomar directamente a Response.Body porque implementa la interfaz io.Reader (recordemos que antes dejamos deferido una invocación a Close() que se ejecutará incluso si la lectura falla). Es importante tener en cuenta que la lectura es lineal, como Body no implementa Seeker no se puede “Rebobinar” así que leerlo podría definirse como “consumirlo”.

	doc, err := goquery.NewDocumentFromReader(res.Body)
	if err != nil {
		log.Fatalf("reading site body: %v", err)
	}

Luego de intaniarlo, necesitaremos crear una función anónima aunque bien podríamos utilizar una función común, esto es solo por practicidad y por la nula reutilizabilidad de la misma.

	const USD = "Dolar U.S.A"

	/* ... */

	var buy, sell string
	var dollar bool

	// Una selección es el resultado de un filtro o búsqueda dentro del DOM
	// en ete caso dicho filtro se hará mas adelante y el resultado se pasará
	// a esta función anónima.
	extractUSD := func(i int, innerS *goquery.Selection) {
		// Buscamos un elemento con la clase y cuyo texto tenga lo que buscamos, este criterio
		// lo obtuvimos de analizar el código HTML de la pagina detenidamente el la 
		// sección que nos interesa.
		if innerS.HasClass("tit") && innerS.Text() == USD {
			// utilizamos el flag dollar para denotar que en efecto este nodo es el inicio
			// de los datos de cotización, si es true significa que los valores a continuación son la cotización
			dollar = true
			return
		}
		// i indica cual de los nodos de esta selección tenemos (de 0 a N)
		// en este caso 0 es el título de la sección, 1 la cotización comprador
		// y 2 vendedor.
		if dollar && i == 1 {
			buy := innerS.Text()
		}
		if dollar && i == 2 {
			sell := innerS.Text()
			// finalmente reseteamos el contador, esto nos garantiza que ignoramos los siguientes
			// nodos si los hubiese, esto es un detalle de esta implementación en particular.
			dollar = false
		}
	}

Una vez que hemos definido nuestra función de busqueda dentro del HTML la invocaremos utilizando el método Find del Document, para este caso filtraremos “tags <tr> dentro de nodos con id billetes, dado que esto devuelve un set filtrado, podemos invocar el método Each que recibe una función con la misma firma que nuestra función anónima y la ejecuta una vez por cada nodo del resultado, pasando oportunamente la posición del nodo en la lista de resultados y un objeto que lo representa.

Para nuestro ejemplo hicimos una función anónima intermedia que tomará la selección pasada y dentro de la misma filtrará por los nodos <td> antes de invocar extractUSD en cada uno de ellos.

	// Find the review items
	doc.Find("#billetes tr").Each(func(i int, s *goquery.Selection) {
		s.Find("td").Each(extractUSD)
	})

Para terminar, vamos a convertir el resultado obtenido a un formato numérico apto para manejar dinero, Decimal (En inglés) qe es un formato que nos permite guardar valores que reprensentan dinero y operar con ellos sin tener las pérdidas de precisión típicas del rendondeo de numeros con coma flotante

// El banco utiliza `,` como indica la localización de Argentina, pero la computadora
// espera `.`
sell := strings.Replace(sell, ",", ".", -1)
// obtendremos entonces el decimal con un constructor que espera una representación textual
// del número a convertir.
numericSell, err := decimal.NewFromString(sell)
if err != nil {
	log.Fatalf("no se puede convertir el valor de venta a Decimal: %v", err)
}

Finalmente, podremos ver el programa entero en un formato simpático para ejecutar con go run de la siguiente manera go run main.go

El archivo main.go cuya salida al ser ejecutado es (al día de hoy) (Nacion) Compra: 55.50 , Venta: 59.00

package main

import (
	"fmt"
	"log"
	"net/http"
	"strings"

	"github.com/PuerkitoBio/goquery"
	"github.com/shopspring/decimal"
)

const bnaURL = "http://www.bna.com.ar/Personas"
const USD = "Dolar U.S.A"

func main() {
	res, err := http.Get(bnaURL)
	if err != nil {
		log.Fatalf("getting bna website: %v", err)
	}

	defer res.Body.Close()
	if res.StatusCode != 200 {
		log.Printf("código de estado de la petición inesperado: %d %s", res.StatusCode, res.Status)
		return
	}

	var buy, sell string
	var dollar bool

	// Una selección es el resultado de un filtro o búsqueda dentro del DOM
	// en ete caso dicho filtro se hará mas adelante y el resultado se pasará
	// a esta función anónima.
	extractUSD := func(i int, innerS *goquery.Selection) {
		// Buscamos un elemento con la clase y cuyo texto tenga lo que buscamos, este criterio
		// lo obtuvimos de analizar el código HTML de la pagina detenidamente el la
		// sección que nos interesa.
		if innerS.HasClass("tit") && innerS.Text() == USD {
			// utilizamos el flag dollar para denotar que en efecto este nodo es el inicio
			// de los datos de cotización, si es true significa que los valores a continuación son la cotización
			dollar = true
			return
		}
		// i indica cual de los nodos de esta selección tenemos (de 0 a N)
		// en este caso 0 es el título de la sección, 1 la cotización comprador
		// y 2 vendedor.
		if dollar && i == 1 {
			buy = innerS.Text()
		}
		if dollar && i == 2 {
			sell = innerS.Text()
			// finalmente reseteamos el contador, esto nos garantiza que ignoramos los siguientes
			// nodos si los hubiese, esto es un detalle de esta implementación en particular.
			dollar = false
		}
	}

	doc, err := goquery.NewDocumentFromReader(res.Body)
	if err != nil {
		log.Printf("reading site body: %v", err)
		return
	}

	// Find the review items
	doc.Find("#billetes tr").Each(func(i int, s *goquery.Selection) {
		s.Find("td").Each(extractUSD)
	})

	// El banco utiliza `,` como indica la localización de Argentina, pero la computadora
	// espera `.`
	sell = strings.Replace(sell, ",", ".", -1)
	buy = strings.Replace(buy, ",", ".", -1)

	// obtendremos entonces el decimal con un constructor que espera una representación textual
	// del número a convertir.
	numericSell, err := decimal.NewFromString(sell)
	if err != nil {
		log.Printf("no se puede convertir el valor de venta a Decimal: %v", err)
		return
	}
	numericBuy, err := decimal.NewFromString(buy)
	if err != nil {
		log.Printf("no se puede convertir el valor de compra a Decimal: %v", err)
		return
	}

	fmt.Printf("(Nacion) Compra: %s , Venta: %s", numericBuy.StringFixedBank(2), numericSell.StringFixedBank(2))
}
comments powered by Disqus