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 HTTP que en nuestro caso sera un GET
- El parser HTML
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 deerr
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 unio.ReaderCloser
que exporta la interface de unio.Reader
y un ‘io.Closer’ y nos permite tratar aBody
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))
}