CoderCastrov logo
CoderCastrov
Puppeteer

El encantador de serpientes, pyppeteer

El encantador de serpientes, pyppeteer
просмотров
13 мин чтение
#Puppeteer

O cómo controlar un navegador remotamente desde python.

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?

Решение: Браузер с использованием Pyppeteer!

Pyppeteer, написанный на Python, является портом Puppeteer, библиотеки JavaScript для управления и автоматизации Chrome / Chromium, разработанной Google. Это современная и очаровательная библиотека для управления нашим браузером. Она позволяет нам почти полностью контролировать Chromium / Chrome, открывать вкладки, анализировать DOM в реальном времени, выполнять JavaScript, подключаться к работающему браузеру и даже загружать Chromium.

До недавнего времени использование браузера для таких задач требовало использования проектов, таких как PhantomJS или "обрезанные" браузеры, обычно разработанные на основе кода проекта Chromium. С появлением режима "headless" в Firefox и Chrome даже это уже не требуется. В основном, режим headless позволяет рендерить и интерпретировать страницу без необходимости пользовательского интерфейса, получая тот же результат, что и в традиционном режиме. Это позволяет запускать браузеры на серверах удаленно, без графической среды, и даже использовать их в контейнере 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.

Краткое введение в веб-парсинг.

Для тех, кто не знает, что такое парсинг, я проведу небольшую демонстрацию.

Основная идея заключается в загрузке HTML-документа и извлечении из него нужной информации, как это делает веб-браузер.

Мы получим более сложную версию следующей структуры.

<html>
  <head>
    <title>ЗАГОЛОВОК СТРАНИЦЫ</title>
    ...
  </head>
  <body>
    <div>
      <a href='[http://example.com'](http://example.com')>ССЫЛКА</a>
    </div>
    ...
  </body>
</html>

Следующим шагом будет "разбор" документа, то есть анализ различных элементов структуры, чтобы выделить и сохранить нужную информацию. Используя предыдущую структуру, например, мы можем извлечь заголовок страницы "ЗАГОЛОВОК СТРАНИЦЫ" или свойство href элемента ссылки "http://example.com".

Давайте воспользуемся Python и извлечем некоторую информацию из Википедии, запросив некоторые страницы о различных языках программирования и извлекая информацию из таблиц сводки.

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)})

Это основная часть программы. Сначала у нас есть словарь с URL-адресами целевых страниц. Для каждой из них мы запрашиваем страницу с помощью get_page(url), которая возвращает ответ сервера, и читаем ответ с помощью функции read_document(response), которая возвращает готовый для интерпретации документ.

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


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

Теперь, с помощью функции extract_data(), мы разбираем и извлекаем нужную информацию.

def extract_data(document):
    # Создание дерева документа
    tree = lxml.html.fromstring(document)
    # Выбор tr с потомками th и td из таблицы
    elements = tree.xpath('//table[@class="infobox"]/tr[th and td]')
    # Извлечение данных
    result = {}
    for element in elements:
        th, td = element.iterchildren()
        result.update({
            th.text_content(): td.text_content()
        })
    return result

С помощью lxml.html.fromstring() мы разбираем документ и получаем дерево элементов. С помощью xpath мы выбираем узлы tr из таблицы, у которых есть потомки th и td, а затем извлекаем текст, который они содержат, с помощью метода text_content(). Извлеченные данные для каждого URL будут выглядеть примерно так:

...
'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
... 

Вот полный скрипт.

Pyppeteer

Давайте посмотрим, как установить и использовать pyppeteer для парсинга данных с веб-страницы. Сначала создадим виртуальное окружение Python с помощью pipenv и установим библиотеку.

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

После этого у нас будет все необходимое для начала использования pyppeteer, но перед этим, при первом запуске (если не указан путь к исполняемому файлу Chrome/Chromium), библиотека загрузит Chromium, который займет примерно 100 МБ.

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 resultif __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)

Это будет основа нашей программы, очень похожая на предыдущую, за исключением использования asyncio и синтаксиса async/await. Функция extract_all(languages) будет точкой входа в наше приложение, она получит словарь целевых URL-адресов и вызовет функцию get_browser(), которая запустит браузер. Поскольку мы передали параметр {'headless': False} в launch, мы сможем увидеть, как браузер запускается и автоматически загружает URL-адрес.

Затем мы пройдемся по словарю URL-адресов и будем вызывать функцию extract, передавая ей URL-адрес, которая в свою очередь вызовет get_page, которая откроет новую вкладку в браузере и загрузит URL-адрес.

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

Наконец, extract_data будет выполнять извлечение данных. Мы будем использовать селектор xpath//table[@class='infobox']/tbody/tr[th and td], чтобы выбрать узлы tr, являющиеся потомками таблицы и имеющие оба дочерних узла th и td. Для каждого из них мы извлечем текст узла. И вот самая интересная часть, и, вероятно, та, которая меньше всего понравится пуристам: для извлечения текста мы передадим функцию, написанную на JavaScript, которая будет выполнена в браузере, и мы получим результат.

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

Результат будет точно таким же, как в предыдущем разделе. Вот краткий пример:

...
'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
...

И вот полный скрипт:

Парсинг более сложной страницы

Мы сможем увидеть настоящий потенциал библиотеки, извлекая данные с динамической страницы. Для этого я получил разрешение от разработчиков http://coinmarketcap.io на использование ее в качестве цели. (Большое спасибо и поздравления с отличным приложением!).

Coinmarketcap является SPA (одностраничное приложение), и после загрузки в браузере различные функции приложения будут добавлять, изменять и удалять узлы DOM в зависимости от взаимодействия пользователя, поэтому загрузка копии HTML и его разбор нам не поможет.

Цель нашего парсера - получить информацию о деталях первых 30 криптовалют, отсортированных по общей капитализации, и получить данные за последние 24 часа в евро.

Детали криптовалюты NANO.

Приступим к работе: Начало будет очень похожим на предыдущий пример, у нас будет функция scrape_cmc_io(), которая выполнит различные задачи и соберет полученную информацию. get_browser запустит браузер Chromium, а get_page загрузит приложение в новой вкладке.

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))
Начальный экран coinmarketcap.io

Первое, что мы видим при первом доступе к приложению, это запрос на создание аккаунта или запрос на вход в систему. Давайте создадим аккаунт, вызвав create_account, как мы можем видеть, это всего лишь один клик '(just one click)', поэтому мы нажимаем на кнопку, используя метод .click(selector), передавая идентификатор кнопки.

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

Затем мы перейдем на главный экран. По умолчанию суммы отображаются в долларах, а наше требование - извлечь информацию в евро. Чтобы увидеть евро, нам нужно перейти в поисковик валют и найти нужную валюту, добавить ее и выбрать валюту для отображения сумм.

Поиск фиатных валют.

Для этого мы создаем функцию add_eur, которая будет последовательно выбирать различные элементы и нажимать на них. Новшество заключается в том, что мы вводим текст в поисковую строку с помощью метода page.type(selector, 'eur'), который имитирует ввод с клавиатуры.

Еще одна особенность - использование метода .waitForSelector, который ожидает заданное количество миллисекунд, пока желаемый узел не появится в DOM, если это не произойдет, будет сгенерировано исключение.

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)

Следующее требование - получить информацию о первых 30 монетах. По умолчанию приложение будет показывать 25, поэтому нам нужно получить доступ к соответствующему меню, чтобы выбрать нужное количество.

Общее количество отображаемых монет.

За это будет отвечать функция select_top30 с очень похожим функционированием, нажатием на нужный селектор, ожиданием и повторным нажатием.

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)

Теперь нам остается только получить детали каждой монеты и извлечь информацию. Чтобы открыть детали, мы выберем узел-контейнер монет и будем последовательно нажимать на каждого ребенка.

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

Чтобы браузер мог нажимать на узлы, они должны быть видимы в "viewport" - части веб-страницы, которую мы можем видеть. Мы выполним функцию JavaScript, которая будет прокручивать страницу по мере прохождения узлов.

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

Интересно, что в процессе разработки программы я обнаружил, что когда монета оказывается под баннером рекламы, клик происходит по баннеру.

Внутри деталей мы извлечем информацию с помощью той же схемы, что мы использовали ранее, выберем нужный узел и будем взаимодействовать с ним. Это делает функция extract_currency. Мы очистим данные, исключив нежелательные символы и преобразуем суммы в числа, а также удалим лишние пробелы и переносы строк. Мы извлечем имя, символ, текущую цену, изменение цены за последние 24 часа, процент изменения за 24 часа и позицию в рейтинге по общей капитализации.

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
    }

Наконец, чтобы придать программе завершающий штрих, мы будем использовать библиотеки terminaltables и colorclass, чтобы улучшить вывод в терминале. Мы будем отображать в красном цвете те, которые упали за последние 24 часа, и в зеленом цвете те, которые не упали.

Извлеченные и отформатированные данные с использованием terminaltables и colorclass

Полная программа.

Заключение.

Pyppeteer позволяет управлять современным браузером из кода на Python с помощью относительно простого и высокоуровневого API, что делает его альтернативой традиционному Selenium. Автор стремится полностью эмулировать API Puppeteer. В настоящее время библиотека активно разрабатывается, на момент написания статьи она только что обновилась до версии 0.0.17 и, хотя она помечена как "альфа", достаточно стабильна для использования.

Я хотел бы поблагодарить автора pyppeteer за его преданность и время, затраченные на разработку библиотеки, команду coinmarketcap.io за предоставленную мне возможность использовать их приложение для этого руководства, а также Commite за то, что позволил мне улучшить свои знания asyncio в процессе написания статьи.