190: Screen scraping con Nokogiri (view original Railscast)
En el episodio 173 [verlo, leerlo] vimos la llamada técnica de screen-scraping con la libreria ScrAPI. El screen-scraping es una prácticaa muy habitual y hay un número de gemas y plugins para hacerlo más fácilmente, así que en este capítulo volveremos a tratar este tema pero usando herramientas diferentes. Se recomienda repasar el otro capítulo después de leer este de forma que se puedan comparar ambos enfoques y decidir cuál preferimos.
Tal y como hicimos la otra vez tenemos una aplicación que muestra una lista de productos sin precios y queremos encontrar los precios de dichos productos desde otra web, en este caso walmart.com.
Si buscamos un producto en la página web de Walmart iremos a una página que muestra un listado de productos que tienen un nombre parecido así como sus precios. Podemos utilizar esta página para introducir estos precios en nuestra aplicación.
La página de resultados de “Batman”
Tal y como mencionábamos en el episodio anterior sobre screen-scraping es importante que nos aseguremos de que tenemos permiso para obtener datos desde el sitio de origen porque algunas páginas prohiben explícitamente recuperar datos utilizando screen-scraping. Si el sitio proporciona feeds RSS o bien una API que permita recuperar los datos que necesitamos, siempre es mejor utilizar alguna de estas opciones . Dado que el sitio de Walmart no tiene ninguna de estas posibilidades tendremos que recurrir al screen-scraping.
Instalación de Nokogiri
Si la última vez utilizamos una librería llamada ScrAPI, esta vez vamos a utilizar Nokogiri, que puede analizar documentos HTML y XML y extraer el contenido de ellas. Nokogiri es rápida y la principal diferencia con respecto a ScrAPI es que parece que estamos trabajando directamente con el documento HTML en lugar de hacerlo todo a través de un DSL.
Si desarrollamos en un Mac con Snow Leopard entonces la instalación de Nokogiri es tan fácil como ejecutar
sudo gem install nokogiri
Si por el contrario estamos usando una versión más antigua de OS X u otro sistema operativo probablemente tendremos que instalar la librería libxml2 y luego especificar su ubicación cuando se instale la gema Nokogiri. Por ejemplo, si instalamos libxml2 en /usr/local el comando tendrá el siguiente aspecto:
sudo gem install nokogiri -- --with-xml2-include=/usr/local/include/libxml2 --with-xml2-lib=/usr/local/lib
Hay más información sobre la instalación de Nokogiri en el tutorial del site oficial, donde hay instrucciones más detalladas para OS X, Linux y Windows.
En marcha con Nokogiri
Una vez que hemos instalado Nokogiri podemos empezar a usarla. Nokogiri admite tanto XPath como selectores CSS3, y esto último la hace especialmente últil a la hora de extraer datos a partir de documentos HTML.
Empezaremos nuestros experimentos con Nokogiri en un script normal de Ruby antes de incorporarla a nuestra aplicación Rails. Utilizando la URL de los resultados de "Batman" en el site de Walmart intentaremos extraer el título de la página.
require 'rubygems'
require 'nokogiri'
require 'open-uri'
url = "http://www.walmart.com/search/search-ng.do?search_constraint=0&ic=48_0&search_query=Batman&Find.x=0&Find.y=0&Find=Find"
doc = Nokogiri::HTML(open(url))
puts doc.at_css("title").text
Además de requerir la gema nokogiri también requeriremos open-uri para poder obtener fácilmente los contenidos de una URL. Después crearemos un nuevo documento HTML en Nokogiri utilizando los contenidos de la página de resultados. Sobre este documento Nokogiri podemos usar at_css para recuperar los contenidos del elemento <title>. El método at_css nos devuelve el primer elemento encontrado y sobre él se puede invocar el método .text para imprimir su contenido textual.
Si ejecutamos nuestro script veremos el contenido del título de la página.
Search results for Batman - Walmart
Ahora vamos a intentar algo un poco más complicado: obtener el nombre y precio de cada producto en la página de resultados. Lo primero que tendremos que hacer es determinar los selectores CSS que seleccionan las partes que nos interesan de la página. En el anterior episodio sobre screen scraping utilizamos un plugin de Firefox pero esta vez podremos usar un bookmarklet llamado SelectorGadget. Para usarlo simplemente tenemos que arrastrar el enlace desde el site oficial a la barra de marcadores de nuestro navegador. Dado que es un bookmarklet, SelectorGadget funciona correctamente en Safari y Firefox.
De vuelta en la página de resultados de búsqueda queremos determinar el selector CSS que selecciona el título de cada item. Si hacemos clic en el primer título de la página, veremos el selector que la escoge. Si hacemos scroll también veremos los otros elementos que son escogidos por el selector. El selector que tenemos, .prodLink escoge todos los títulos de la página, que es exactamente lo que buscamos, así que ya hemos encontrado nuestro selector.
Ahora vamos a intentar deducir el selector que escoge el precio de cada ítem. Esto es un poco más complicado porque si escogemos uno de los precios en la parte superior de la página no se escogen todos los precios de todos los items. Si luego seleccionamos uno de los precios que permanecen sin seleccionar se escogeran tanto todos los precios como también otros elementos de la página. Si luego escogemos estos últimos elementos no deseados para deseleccionarlos, nos quedarmemos sólo con los precios marcados y podemos copiar el selector que nos da SelectorGadget, que es .PriceXLBold, .PriceCompare .BodyS.
El último selector que nos hace falta es el que escoge todos los items de la lista. Si hacemos clic en el primero obtendremos un selector que tan sólo escoge el primer elemento. Una vez que escojamos el segundo item, tendremos un selector que escoge todos los items de la página, .item.
Y una vez que tenemos todos los selectores CSS que necesitamos los podemos utilizar en nuestro script para extraer la información que nos interesa:
require 'rubygems'
require 'nokogiri'
require 'open-uri'
url = "http://www.walmart.com/search/search-ng.do?search_constraint=0&ic=48_0&search_query=Batman&Find.x=0&Find.y=0&Find=Find"
doc = Nokogiri::HTML(open(url))
doc.css(".item").each do |item|
puts item.at_css(".prodLink").text
end
Lo que hacemos ahora es utilizar el selector .item para iterar sobre cada elemento y extraer de cada uno el título con el selector .prodLink. Si ejecutamos otra vez el script, veremos listado el título de cada item.
$ ruby test.rb Batman Batman: No Man's Land Batman: No Man's Land - Vol 03 Batman: No Man's Land - Vol 02 Fisher-Price Batman Lights and Sounds Trike Batman: Arkham Asylum (PS3) LEGO Batman (DS) LEGO Batman (Wii) DC Universe Batman / Superman / Catwoman / Lex Luthor / Two-Face Figures Batman Begins (Blu-ray) (Widescreen) LEGO Batman (Xbox 360)
Por supuesto también queremos mostrar el precio de cada item, así que tendremos que modificar otra vez nuestro script:
require 'rubygems'
require 'nokogiri'
require 'open-uri'
url = "http://www.walmart.com/search/search-ng.do?search_constraint=0&ic=48_0&search_query=Batman&Find.x=0&Find.y=0&Find=Find"
doc = Nokogiri::HTML(open(url))
doc.css(".item").each do |item|
text = item.at_css(".prodLink").text
price = item.at_css(".PriceXLBold, .PriceCompare .BodyS").text[/\$[0-9\.]+/]
puts "#{text} - #{price}"
end
Recuperamos el título igual que antes y luego utilizamos el selector CSS para sacar el precio del item. El elemento que contiene el precio también tiene algo de texto, por ejemplo: "Our price: $6.99", así que tenemos que utilizar una expresión regular para extraer el signo del dólar y cualquier número y puntos decimales que haya. Cuando ejecutemos el script otra vez, tendremos el título y el precio de cada ítem de la página:
$ ruby test.rb Batman - $6.86 Batman: No Man's Land - $11.50 Batman: No Man's Land - Vol 03 - $11.50 Batman: No Man's Land - Vol 02 - $9.50 Fisher-Price Batman Lights and Sounds Trike - $43.21 Batman: Arkham Asylum (PS3) - $59.82 LEGO Batman (DS) - $19.82 LEGO Batman (Wii) - $19.82 DC Universe Batman / Superman / Catwoman / Lex Luthor / Two-Face Figures - $44.00 Batman Begins (Blu-ray) (Widescreen) - $11.32 LEGO Batman (Xbox 360) - $19.82
¿Y si queremos también obtener la URl de cada item? Pues bien, el elemento que almacena el título del producto es un elemento anchor, y su atributo href contiene la URL así que todo lo que tenemos que hacer será extraer el valor de dicho atributo. Podemos hacerlo con la siguiente línea de código:
item.at_css(".prodLink")[:href]
Uniendo todas las piezas
Ahora que sabemos utilizar Nokogiri para extraer los datos de una página web, podemos usar lo que hemos aprendido en nuestra aplicación Rails para recuperar el precio de cada producto. Podemos hacerlo con una tarea de Rake, así que en el directorio /lib/tasks de nuestra aplicación crearemos un nuevo archivo llamado product_prices.rake donde pondremos nuestra tarea.
El código de esta tarea de Rake será similar al código del script Ruby que escribimos anteriormente. La tarea empieza declarando la dependencia de la tarea :environment para cargar el entorno de Rails. Después encuentra todos los productos en la base de datos que no tienen un precio y las recorre.
Para cada producto queremos obtener la URL apropiada de búsqueda. Tendremos que escapar en primer lugar el nombre del producto con CGI::Escape para que sea posible incluirlo en una URL. Una vez que hemos montado la URL podemos abrirla con Nokogiri y utilizar el selector CSS de nuestro escript en Ruby para extraer el precio. Hay un pequeño cambio en la expresión regular para dejar de incluir el símbolo del dólar. Una vez que tenemos el precio, podemos actualizar el producto.
desc "Fetch product prices"
task :fetch_prices => :environment do
require 'nokogiri'
require 'open-uri'
Product.find_all_by_price(nil).each do |product|
escaped_product_name = CGI.escape(product.name)
url = "http://www.walmart.com/search/search-ng.do?search_constraint=0&ic=48_0&search_query=#{escaped_product_name}&Find.x=0&Find.y=0&Find=Find"
doc = Nokogiri::HTML(open(url))
price = doc.at_css(".PriceXLBold, .PriceCompare .BodyS").text[/[0-9\.]+/]
product.update_attribute(:price, price)
end
end
Si ejecutamos ahora la tarea de rake
rake fetch_prices
se ejecuta sin errores, así que podemos ir a nuestra página de productos y ver qué ha pasado.
Ahora todos los productos muestran su precio basado en la información de Walmart.
Con Nokogiri y SelectorGadget hemos conseguido extraer datos de otra web. Juntos conforman un par de herramientas indispensables para el screen scraping. Pero, ¿y si necesitamos interactuar más con la web de la que sacamos datos, por ejemplo, hacer login en primer lugar? Para esto podemos utilizar Mechanize, de la que hablaremos en el próximo episodio.



