El encantador de serpientes, pyppeteer

O cómo controlar un navegador remotamente desde python.

Slothy
Z1 Digital Studio
12 min readApr 10, 2018

--

Foto: Jordan Gellie

Tras años desarrollando software, una de las labores que más me gusta cuando me involucro en un nuevo proyecto, es investigar las posibles soluciones a utilizar. Gracias a la enorme cantidad de software libre disponible a día de hoy, hacerlo puede ayudarte a encontrar el enfoque más apropiado a un problema y a veces, con suerte, la solución directa. Pero incluso si no eres afortunado, seguro que por el camino encuentras utilidades, librerías y software que en futuro te pueden resultar útiles o como mínimo curiosas.

Así es como llegué a pyppeteer, un port a python de puppeteer, buscando información para satisfacer parte de los requerimientos de uno de los últimos proyectos en los que participo en Commite Inc., que consiste en extraer y analizar datos de distintas páginas y web apps.

La verdad es que no es un ámbito inexplorado precisamente en el mundo del desarrollo web, sobre todo con los lenguajes que solemos utilizar en el stack de Commite: python y javascript. Ambos lenguajes tienen una enorme cantidad de proyectos y librerías disponibles relacionados con el “scraping” y el testeo de aplicaciones web, tantas que incluso se vuelve complicado decidir cuáles usar.

Pero volviendo a los requerimientos del proyecto, y en concreto a la extracción de información de las distintas webs, algunas son muy dinámicas, algunas de ellas están hechas con React, otras con angular y otras tienen partes en javascript con el viejo amigo JQuery. Todo esto, a priori, no debería ser un problema. El problema es que ‘la web’ hoy en día es bastante compleja, y puede que los datos que necesitamos sólo los encontremos interactuando con la interfaz o clicando un botón que realiza una llamada ajax al servidor. Incluso puede que una sección sólo aparezca si se posa el cursor sobre un elemento o peor aun, puede que toda la estructura de la página sea dinámica como en una SPA.

¿Y si pudiésemos extraer directamente la información de un navegador y manejarlo de forma automatizada? ¿Y si pudiésemos controlar el puntero o simular la entrada de datos por teclado?

La solución: Un navegador ¡Que entre pyppetter!

Pyppeteer, escrito en python, es un port de puppeteer, una librería de Javascript para el control y automatización de Chrome / Chromium, desarrollada por Google. Es un moderno encantador de serpientes para nuestro navegador. Nos permite un control casi total de un Chromium / Chrome, abrir pestañas, analizar el DOM en tiempo real, ejecutar Javascript, conectar a un navegador en ejecución e incluso descargar un Chromium.

Hasta hace relativamente poco tiempo, poder usar un navegador para este tipo de tareas requería echar mano de proyectos como PhantomJS o navegadores “recortados”, normalmente desarrollados sobre la base del código del proyecto Chromium. Con la incorporación de los modos “headless” a Firefox y Chrome ya ni eso es necesario. Básicamente el modo headless permite renderizar e interpretar una página sin necesidad de la interfaz de usuario, obteniendo el mismo resultado que en el modo tradicional. Esto hace que los navegadores puedan ser ser ejecutados en servidores, remotamente, prescindiendo del entorno gráfico, e incluso utilizarlos en un contenedor Docker.

¿Qué alternativas hay disponibles?

La idea de controlar un navegador parte del venerable Selenium. Sin entrar en demasiado detalle, Selenium son una serie de tecnologías para controlar el navegador de forma remota, además, desde hace bastante tiempo Selenium es el estándar de facto para la tarea. Desarrollado en Java, funciona prácticamente en cualquier navegador y tiene librerías para prácticamente cualquier lenguaje. Sin embargo, el W3C está en proceso de estandarización de WebDriver (que es la estandarización de un protocolo de manejo remoto de navegadores) siendo GeckoDriver y ChromeDriver sus respectivas implementaciones para Firefox y Chrome.

  • En concreto Firefox dispone de Marionette, que es bastante sencilla de utilizar y está decentemente documentada. De hecho fue mi elección inicial para el proyecto, sin embargo tiene varias pegas: de momento sólo soporta python 2.7 (¡ánimo Mozilla!) por dependencias de base de la librería y no es asíncrona, por lo que resulta un poco extraño trabajar con ella.
  • En el caso de Chromium, tiene DevTools protocol como protocolo de comunicación a bajo nivel, ofreciendo mucha funcionalidad y sobre éste, el más conocido Puppeteer en Javascript, muy utilizado, bien documentado y usado como base para otras librerías.
  • Y relacionado, en python, por supuesto existe Scrapy y también he encontrado esta joyita http://html.python-requests.org/ del creador de requests y pipenv entre otros (buceando en su código fue como descubrí pyppeteer).

Ahora, si ya sabes que es el scraping y quieres ver directamente a pyppeteer en acción, puedes ir directamente al tutorial tras la siguiente sección.

Breve introducción al “scrapeado” web.

Para aquellos que no sepan que es el scraping aquí os voy a hacer una pequeña demostración.

La idea básica consiste en descargar, como lo haría un navegador, el ‘documento’ HTML y extraer de él la información que se quiera.

Lo que obtendremos será una versión más compleja del siguiente esquema.

<html>
<head>
<title>PAGE TITLE</title>
...
</head>
<body>
<div>
<a href='http://example.com'>A LINK</a>
</div>
...
</body>
</html>

El siguiente paso será “parsear” el documento, lo que sería analizar los distintos elementos de la estructura, para poder distinguirlos y quedarnos con lo que nos interesa, usando el anterior esquema, por ejemplo, extraer el título de la página, “PAGE TITLE” o la propiedad href del elemento enlace “http://example.com”.

Usemos python y extraigamos algo de información de Wikipedia, vamos a pedir algunas páginas de algunos lenguajes de programación y extraigamos la información de las tablas de resumen.

languages = {
"python": "https://es.wikipedia.org/wiki/Python",
...
}
result = {}
for name, url in languages.items():
response = get_page(url)
document = read_document(response)
result.update({name: extract_data(document)})

Esta es la parte principal del programa, primero tenemos un diccionario con las urls de las páginas objetivo, por cada una de ellas, pediremos la pagina conget_page(url) que nos devolverá la respuesta del servido y leeremos la respuesta con la función read_document(response) que devolverá el documento listo para ser interpretado.

def get_page(url):
return request.urlopen(url)


def read_document(response):
return response.read()

Ahora, con la función extract_data() parsearemos y extraeremos la información que nos interesa.

def extract_data(document):
# Generate document tree
tree = lxml.html.fromstring(document)
# Select tr with a th and td descendant from table
elements = tree.xpath('//table[@class="infobox"]/tr[th and td]')
# Extract data
result = {}
for element in elements:
th, td = element.iterchildren()
result.update({
th.text_content(): td.text_content()
})
return result

Con lxml.html.fromstring() parseamos el documento obteniendo un ábol de elementos, mediante xpath seleccionamos los nodos tr de la tabla que tengan un nodo th y td como descendientes, y de ellos obtemenos el texto que contienen con el método text_content() . Los datos extraidos por cada una de las urls seran algo similar a lo siguiente.

...
'python': {'Apareció en': '1991',
'Dialectos': 'Stackless Python, RPython',
'Diseñado por': 'Guido van Rossum',
'Extensiones comunes': '.py, .pyc, .pyd, .pyo, .pyw',
'Ha influido a': 'Boo, Cobra, D, Falcon, Genie, Groovy, Ruby, '
'JavaScript, Cython, Go',
'Implementaciones': 'CPython, IronPython, Jython, Python for S60, '
'PyPy, Pygame, ActivePython, Unladen Swallow',
'Influido por': 'ABC, ALGOL 68, C, Haskell, Icon, Lisp, Modula-3, '
'Perl, Smalltalk, Java',
'Licencia': 'Python Software Foundation License',
'Paradigma': 'Multiparadigma: orientado a objetos
...

Aquí el script al completo.

Scraping básico de wikipedia con python y lxml.

Pyppeteer

Vamos a ver como instalar y usar pyppeteer para hacer el mismo scrapeado de la wikipedia. Primero generaremos un virtualenv de python con pipenv e instalaremos la librería.

$ pipenv --three
$ pipenv shell
$ pipenv install pyppeteer

Con esto tendremos lo necesario para empezar a utilizar pyppeteer, pero antes, la primera vez que se ejecute (a menos que se especifique un path ejecutable de Chrome/Chromium), la librería nos descargará un Chromium, aproximadamente unos 100mb.

import pprint
import asyncio
from pyppeteer import launch


async def get_browser():
return await launch({"headless": False})
...async def extract_all(languages):
browser = await get_browser()
result = {}
for name, url in languages.items():
result.update(await extract(browser, name, url))
return result
if __name__ == "__main__":
languages = {
"python": "https://es.wikipedia.org/wiki/Python",
...
}

loop = asyncio.get_event_loop()
result = loop.run_until_complete(extract_all(languages))

pprint.pprint(result)

Este será el esqueleto de nuestro programa, muy similar al anterior, con la salvedad del uso de asyncio y la sintáxis async/await . La función extract_all(languages) será el punto de entrada de nuestra aplicación, recibirá el diccionario de urls objetivo, e invocará a las función get_browser() que lanzarán un navegador. Al haber pasado el parámetro {'headless': False} a launch podremos ver el navegador ejecutarse y cargar la url automáticamente.

Lo siguente será recorrer el diccionario de urls e ir invocando a la funcion extract, pasándole la url que a su vez invocará a get_page, que nos abrirá una nueva pestaña en el navegador y cargará la url.

async def get_page(browser, url):
page = await browser.newPage()
await page.goto(url)
return page
async def extract(browser, name, url):
page = await get_page(browser, url)
return {name: await extract_data(page)}

Finalmente, extract_data realizará la extracción de datos. Utilizaremos un selector xpath //table[@class='infobox']/tbody/tr[th and td] para seleccionar de la página los nodos tr descendientes de la tabla y que tenga ambos hijos th y td . Por cada uno de ellos extraeremos el texto del nodo. Y ahora llega la parte más peculiar, y la que probablemente menos guste a los más puristas: para extraer el texto pasaremos una función escrita en Javascript que será ejecutada en el navegador y de la que se nos devolverá el resultado.

async def extract_data(page):
# Select tr with a th and td descendant from table
elements = await page.xpath(
'//table[@class="infobox"]/tbody/tr[th and td]')
# Extract data
result = {}
for element in elements:
title, content = await page.evaluate(
'''(element) =>
[...element.children].map(child => child.textContent)''',
element)
result.update({title: content})
return result

El resultado será exactamente el mismo de la sección anterior. A continuación un extracto:

...
'python': {'Apareció en': '1991',
'Dialectos': 'Stackless Python, RPython',
'Diseñado por': 'Guido van Rossum',
'Extensiones comunes': '.py, .pyc, .pyd, .pyo, .pyw',
'Ha influido a': 'Boo, Cobra, D, Falcon, Genie, Groovy, Ruby, '
'JavaScript, Cython, Go',
'Implementaciones': 'CPython, IronPython, Jython, Python for S60, '
'PyPy, Pygame, ActivePython, Unladen Swallow',
'Influido por': 'ABC, ALGOL 68, C, Haskell, Icon, Lisp, Modula-3, '
'Perl, Smalltalk, Java',
'Licencia': 'Python Software Foundation License',
'Paradigma': 'Multiparadigma: orientado a objetos
...

Y ahora el script al completo:

Scraping básico de wikipedia con python y pyppeteer.

Scrapeando algo más complejo

Donde podremos ver el verdadero potencial de la librería es extrayendo datos de una página dinámica. Para ello he pedido permiso a los desarrolladores de http://coinmarketcap.io para poder utilizarla como objetivo. (¡Muchas gracias y enhorabuena por la excelente app!).

Coinmarketcap es una SPA, una vez cargada en el navegador, las distintas funcionalidades de la aplicación irán añadiendo, modificando y eleminando nodos del DOM, en función de las interacciones del usuario, así que descargar una copia del HTML y parsearlo no nos servirá de mucho.

El objetivo de nuestro scraper será acceder al detalle del las 30 primeras criptomonedas ordenadas por capitalización total y obtener datos de las últimas 24 horas, en euros.

Detalle de la criptomoneda NANO.

Manos a la obra: El comienzo será muy similar al anterior, tendremos una función scrape_cmc_io() que ejecutará las distintas tareas y recogerá la información obtenida. get_browserlanzará el navegador Chromium y get_page que cargará la app en una pestaña nueva.

import asyncio
from pyppeteer import launch

async def get_browser():
return await launch()


async def get_page(browser, url):
page = await browser.newPage()
await page.goto(url)
return page
...async def scrape_cmc_io(url):
browser = await get_browser()
page = await get_page(browser, url)
await create_account(page)
await select_top30(page)
await add_eur(page)
currencies_data = await navigate_top30_detail(page)
show_biggest_24h_winners(currencies_data)
...if __name__ == "__main__":
url = "http://coinmarketcap.io"

loop = asyncio.get_event_loop()
result = loop.run_until_complete(scrape_cmc_io(url))
Pantalla inicial coinmarketcap.io

Lo primero que vemos al acceder a la app por primera vez es una solicitud para crear una cuenta o una petición para loguearnos. Vamos allá, crearemos una cuenta llamando a create_account, como bien podemos leer, es sólo un click ‘(just one click)’, así que hacemos click en el botón, usando el método .click(selector) pasando el id del botón.

async def create_account(page):
# Click on create account to aceess app
selector = "#createAccountBt"
await page.click(selector)

Tras esto accederemos a la pantalla principal. Por defecto las cantidades aparecen en dólares y nuestro requisito era extraer la información en euros. Para poder ver euros, tenemos que acceder a un buscador de monedas y buscar la deseada, añadirla y seleccionarla como moneda para mostrar cantidades.

Buscador de monedas fiat.

Para ello, creamos la función add_eur , que irá seleccionando los distintos elementos y los irá clickando, la novedad, es que introducimos texto en el buscador mediante el métodopage.type(selector, 'eur') que simula la entrada por teclado.

Otra particularidad es el uso del método .waitForSelector que espera un número configurable de milisegundos a que aparezca el nodo deseado en el DOM, si no es así, se lanzará una excepción.

async def add_eur(page):
# Select EUR fiat currency for the whole app
selector_currency = "#nHp_currencyBt"
await page.click(selector_currency)
selector_add_currency = "#currencyAddBt"
await page.click(selector_add_currency)
selector_search = "input#addCurrencySearchTf"
await page.type(selector_search, 'eur')
selector_euro = "#addCurrencySearchResults > #add_currency_EUR"
await page.waitForSelector(selector_euro)
selector_euro_add = "#add_currency_EUR > .addRemCurrencyBt"
await page.click(selector_euro_add)
selector_use_euro = "#currencyBox > div[data-symbol='EUR']"
await page.click(selector_use_euro)

El siguiente requerimiento es recoger la información de las 30 primeras monedas. Por defecto la aplicación nos mostrará 25, así que tendremos que acceder al menu correspondiente para seleccionar la cantidad deseada.

Total de monedas a mostrar.

De esta parte se encargará la función select_top30 con un funcionamiento muy similar a las anteriores, hacer click en el selector deseado, esperar y clickar de nuevo.

async def select_top30(page):
# Show top 30 currencies by market capitalization
selector_top_list = "#navSubTop"
await page.waitForSelector(selector_top_list)
await page.click(selector_top_list)
selector_top_30 = ".setCoinLimitBt[data-v='30']"
await page.click(selector_top_30)

Ahora ya sólo nos queda acceder al detalle de cada una de las monedas y extraer la información. Para abrir el detalle selecionaremos el nodo contendor de las monedas e iremos recorriendo los hijos, haciendo click en cada uno.

async def navigate_top30_detail(page):
# Iterate over the displayed currencies and extract data
select_all_displayed_currencies = "#fullCoinList > [data-arr-nr]"
select_currency = "#fullCoinList > [data-arr-nr='{}'] .L1S1"
currencies = await page.querySelectorAll(select_all_displayed_currencies)
total = len(currencies)

datas = []
for num in range(total):
currency = await page.querySelectorEval(
select_currency.format(num),
"(elem) => elem.scrollIntoView()"
)
currency = await page.querySelector(select_currency.format(num))
datas.append(await extract_currency(page, currency))
return datas

Para que el navegador pueda hacer click en los nodos, estos deben ser visibles en el “viewport”, que es la parte de la web que podríamos ver, ejecutaremos una función en javascript que irá haciendo scroll a medida que recorremos los nodos.

currency = await page.querySelectorEval(
select_currency.format(num),
"(elem) => elem.scrollIntoView()"
)

Como curiosidad, mientras desarrollaba el programa, descubrí que cuando una moneda queda por debajo del banner de publicidad, el click se hacía sobre el banner.

Dentro del detalle, extraeremos la información mediante el mismo patrón que hemos estado usando, seleccionaremos el nodo deseado y actuaremos sobre él. Esto lo hace la función extract_currenc. Limpiaremos los datos, excluyendo el símbolo no deseados y transformando las cantidades a números, y quitaremos los espacios y saltos de línea sobrantes. De cada detalle de moneda, vamos a extraer el nombre, el símbolo, el precio actual, la variación de precio en las últimas 24 horas, el porcentaje de cambio en 24 horas y la posición en el ranking según la capitalización total.

async def extract_currency(page, currency):
# Extract currency symbol
symbol = await page.evaluate(
"currency => currency.textContent",
currency
)
symbol = symbol.strip()

# Click on current currency
await currency.click()
selector_name = ".popUpItTitle"
await page.waitForSelector(selector_name)
# Extract currency name
name = await page.querySelectorEval(
selector_name,
"elem => elem.textContent"
)
name = name.strip()
# Extract currency actual price
selector_price = "#highLowBox"
price = await page.querySelectorEval(
selector_price,
"elem => elem.textContent"
)
_price = [
line.strip() for line in price.splitlines() if len(line.strip())]
price = parse_number(_price[1])
# Extract currency 24h difference and percentage
selector_24h = "#profitLossBox"
price_24h = await page.querySelectorEval(
selector_24h,
"elem => elem.textContent"
)
_price_24h = [
line.strip() for line in price_24h.splitlines() if len(line.strip())]
perce_24h = parse_number(_price_24h[6])
price_24h = parse_number(_price_24h[-2])
# Extract currency capitalization rank
selector_rank = "#profitLossBox ~ div.BG2.BOR_down"
rank = await page.querySelectorEval(
selector_rank,
"elem => elem.textContent"
)
rank = int(rank.strip("Rank"))
selector_close = ".popUpItCloseBt"
await page.click(selector_close)
return {
"name": name,
"symbol": symbol,
"price": price,
"price24h": price_24h,
"percentage24h": perce_24h,
"rank": rank
}

Para finalizar y como guinda del pastel, vamos a usar las librerías terminaltables y colorclass para mejorar la salida en el terminal. Mostramos en rojo las que han bajado en las últimas 24 horas y en verde las que no.

Datos extraidos y formateados con terminaltables y colorclass

Programa al completo.

Conclusión.

Pyppeteer te permite controlar un navegador moderno desde código python con un API relativamente sencilla y de alto nivel, pudiendo convertirse en una alternativa al uso del tradicional Selenium. El objetivo del autor es emular por completo el API de puppeteer. Esta siendo activamente desarrollada, en el momento de escribir el artículo la librería acaba de pasar a la versión 0.0.17 y aunque está marcada como ‘alpha’, es suficientemente estable para ser usada.

Me gustaría dar las gracias al autor de pyppeteer por su dedicación y tiempo en desarrollar la librería, al equipo de coinmarketcap.io por dejarme usar su aplicación para el tutorial, y a Commite por permitirme mejorar mis conocimientos de asyncio mientras escribía el artículo.

--

--

Slothy
Z1 Digital Studio

Papo, compañero, developer at @z1digitalstudio y cosa humana.