Программирование на языке Python для сбора и анализа данных

Текст лекции: Будылин Р.Я., Щуров И.В., НИУ ВШЭ

Данный notebook является конспектом лекции по курсу «Программирование на языке Python для сбора и анализа данных» (НИУ ВШЭ, 2015-16). Он распространяется на условиях лицензии Creative Commons Attribution-Share Alike 4.0. При использовании обязательно упоминание автора курса и аффилиации. При наличии технической возможности необходимо также указать активную гиперссылку на страницу курса. Фрагменты кода, включенные в этот notebook, публикуются как общественное достояние.

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

JSON и API. Управление браузером в RoboBrowser и Selenium

Работа с API с помощью JSON

В прошлый раз мы обсуждали работу с API. При этом для получения информации от API использовался формат XML. Помимо XML существует другой распространённый формат хранения и передачи структурированной информации, называющийся JSON. JSON расшифровывается как JavaScript Object Notation и изначально возник как подмножество языка JavaScript (пусть вас не вводит в заблуждение название, этот язык ничего не имеет общего с Java), используемое для описания объектов, но впоследствии стал использоваться и в других языках программирования, включая Python. Различные API могут поддерживать либо XML, либо JSON, либо и то, и другое, так что нам полезно научиться работать с обоими типами данных. Поэтому мы рассмотрим пример чтения данных из Википедии как в прошлый раз, но будем использовать формат JSON — на наше счастье, API MediaWiki это позволяет.

Напомним, что нашей задачей является получение списка всех статей из некоторой категории в Википедии. Вот так мы это делали в прошлый раз:

In [20]:
import requests
from bs4 import BeautifulSoup

url = "https://en.wikipedia.org/w/api.php"
params = {
    'action':'query',
    'list':'categorymembers',
    'cmtitle': 'Category:Physics',
    'format': 'xml'
}

g = requests.get(url, params=params)
g.ok
Out[20]:
True

Как и в прошлый раз, мы взяли эти параметры из документации: 'action': 'query' значит, что мы отправляем запрос, чтобы получить содержимое Википедии. Параметр list отвечает на вопрос список чего мы бы хотели получить. В данном случае это categorymembers — список элементов какой-то категории, cmtitle — это название категории, список элементов которой мы хотим получить. 'format' — это формат ответа, который в прошлый раз был xml.

In [21]:
data = BeautifulSoup(g.text, features='xml')
In [22]:
for cm in data.api.query.categorymembers("cm"):
    print(cm['title'])
Physics
Branches of physics
Glossary of classical physics
Outline of physics
Portal:Physics
Classical physics
Epicatalysis
Experimental physics
Hume Feldman
Microphysics

Попробуем теперь использовать JSON. Отличия в способе вызова минимальны: в качестве format указываем json:

In [24]:
url = "https://en.wikipedia.org/w/api.php"
params = {
    'action':'query',
    'list':'categorymembers',
    'cmtitle': 'Category:Physics',
    'format': 'json'
}

g = requests.get(url, params=params)
g.ok
Out[24]:
True

Смотрим, что нам выдали по запросу. Это и есть JSON

In [25]:
r.text
Out[25]:
'{"batchcomplete":"","continue":{"cmcontinue":"page|4d4f4445524e20504859534943530a4d4f4445524e2050485953494353|844186","continue":"-||"},"query":{"categorymembers":[{"pageid":22939,"ns":0,"title":"Physics"},{"pageid":22688097,"ns":0,"title":"Branches of physics"},{"pageid":3445246,"ns":0,"title":"Glossary of classical physics"},{"pageid":24489,"ns":0,"title":"Outline of physics"},{"pageid":1653925,"ns":100,"title":"Portal:Physics"},{"pageid":151066,"ns":0,"title":"Classical physics"},{"pageid":47723069,"ns":0,"title":"Epicatalysis"},{"pageid":685311,"ns":0,"title":"Experimental physics"},{"pageid":48407923,"ns":0,"title":"Hume Feldman"},{"pageid":23581364,"ns":0,"title":"Microphysics"}]}}'

Он очень похож на описание объекта в Python и смысл квадратных и фигурных скобок такой же. Правда, есть и отличия: например, в Python одинарные и двойные кавычки ничем не отличаются, а в JSON можно использовать только двойные. Мы видим, что полученный нами JSON представляет собой словарь, значения которого — строки или числа, а также списки или словари, значения которых в свою очередь также могут быть строками, числами, списками, словарями и т.д. То есть получается такая довольно сложная структура данных.

В данный момент тот факт, что перед нами сложная структура данных, видим только мы — с точки зрения Python, r.text это просто такая строка. Однако в модуле requests есть метод, позволяющий сразу выдать питоновский объект (словарь или список), если результат запроса возвращён в формате JSON. Так что нам не придётся использовать никакие дополнительные библиотеки.

In [26]:
q = r.json()

Видим, что q это словарь

In [27]:
q
Out[27]:
{'batchcomplete': '',
 'continue': {'cmcontinue': 'page|4d4f4445524e20504859534943530a4d4f4445524e2050485953494353|844186',
  'continue': '-||'},
 'query': {'categorymembers': [{'ns': 0, 'pageid': 22939, 'title': 'Physics'},
   {'ns': 0, 'pageid': 22688097, 'title': 'Branches of physics'},
   {'ns': 0, 'pageid': 3445246, 'title': 'Glossary of classical physics'},
   {'ns': 0, 'pageid': 24489, 'title': 'Outline of physics'},
   {'ns': 100, 'pageid': 1653925, 'title': 'Portal:Physics'},
   {'ns': 0, 'pageid': 151066, 'title': 'Classical physics'},
   {'ns': 0, 'pageid': 47723069, 'title': 'Epicatalysis'},
   {'ns': 0, 'pageid': 685311, 'title': 'Experimental physics'},
   {'ns': 0, 'pageid': 48407923, 'title': 'Hume Feldman'},
   {'ns': 0, 'pageid': 23581364, 'title': 'Microphysics'}]}}
In [28]:
type(q)
Out[28]:
dict

Содержательная информация хранится по ключу 'query'. А уже внутри есть ключ 'categorymembers', значением которого является список всех категорий. Каждая категория отображается в виде словаря, записями которого являются разные параметры категории (например, 'title' соответствует названию, а pageid — внутреннему идентификатору в системе).

In [29]:
type(q['query']['categorymembers'])
Out[29]:
list

Это список всех членов категории. Мы можем посмотреть на них с помощью цикла

In [30]:
for cm in q['query']['categorymembers']:
    print(cm['title'])
Physics
Branches of physics
Glossary of classical physics
Outline of physics
Portal:Physics
Classical physics
Epicatalysis
Experimental physics
Hume Feldman
Microphysics

Преимущества JSON в том, что мы получаем готовый объект Python и нет необходимости использовать какие-то дополнительные библиотеки для того, чтобы с ним работать. Недостатком является то же самое: зачастую поиск информации в XML-файле может проводиться более эффективно, чем в JSON. Продемонстрируем это на уже рассмотренном примере. Чтобы получить список всех тегов <cm>, в которых хранилась информация об элементах категории в XML, мы использовали полный «путь»:

for cm in data.api.query.categorymembers("cm"):
    print(cm['title'])

Однако, это можно бы сделать (в данном случае) гораздо короче. Если посмотреть на XML, то можно заметить, что в нём нет других тегов <cm>, кроме тех, которые нам нужны. С другой стороны, Beautiful Soup ищет все теги с данным именем, а не только те, которые являются потомками первого уровня для данного тега. Таким образом, код выше можно было бы переписать более коротко:

In [33]:
for cm in data("cm"):
    print(cm['title'])
Physics
Branches of physics
Glossary of classical physics
Outline of physics
Portal:Physics
Classical physics
Epicatalysis
Experimental physics
Hume Feldman
Microphysics

Конечно data("cm") выглядит короче, чем q['query']['categorymembers']. В JSON мы не можем использовать подобные методы. Так что у обоих форматов есть свои плюсы и минусы.

Эмуляция действий с браузером

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

Рассмотрим эту задачу на примере работы с сервисом informatics.mccme.ru, который мы использовали для сдачи задач в начале нашего курса.

RoboBrowser

Пакет robobrowser позволяет работать с неким виртуальным браузером, который позволяет ходить по страничкам и получать их содержимое. На самом деле, этот браузер полностью эмулируется Python: фактически robobrowser представляет собой надстройку над requests и BeautifulSoup, позволяющую несколько упростить типичные операции типа «найти ссылку и пройти по ней».

In [69]:
from robobrowser import RoboBrowser

Если вдруг Python ругается, что нет каких-то модулей, то сделайте pip install имя_модуля в консоли.

In [70]:
q = RoboBrowser()

Мы создали виртуальный браузер.

In [71]:
ref = 'http://informatics.mccme.ru'
q.open(ref)

И сказали ему открыть ссылку. Мы можем посмотреть на html содержимое страницы командой ниже

In [72]:
# мне пришлось немного поколодовать, чтобы вывод получился не слишком длинным, 
# но можно было написать просто
# print(q.parsed.text)

for l in q.parsed.text.splitlines()[0:50]:
    # выведем первые несколько строк
    if l.strip():
        # пропустим пустые строки
        
        print(l)
Дистанционная подготовка
//<![CDATA[
setTimeout('fix_column_widths()', 20);
function openpopup(url,name,options,fullscreen) {
  fullurl = "http://informatics.mccme.ru" + url;
  windowobj = window.open(fullurl,name,options);
  if (fullscreen) {
     windowobj.moveTo(0,0);
     windowobj.resizeTo(screen.availWidth,screen.availHeight);
  }
  windowobj.focus();
  return false;
}
function uncheckall() {
  void(d=document);
  void(el=d.getElementsByTagName('INPUT'));
/usr/local/lib/python3.5/site-packages/bs4/__init__.py:166: UserWarning: No parser was explicitly specified, so I'm using the best available HTML parser for this system ("lxml"). This usually isn't a problem, but if you run this code on another system, or in a different virtual environment, it may use a different parser and behave differently.

To get rid of this warning, change this:

 BeautifulSoup([your markup])

to this:

 BeautifulSoup([your markup], "lxml")

  markup_type=markup_type))

Не пугайтесь красного warning выше — просто Beautiful Soup предупреждает, что мы (а точнее разработчики RoboBrowser) не указали ему, какой парсер использовать, и он использовал самый лучший из доступных (с его точки зрения).

Найдём ту форму, которая соответствует вводу пароля. В браузере с помощью просмотра кода элемента, мы можем посмотреть кусок HTML, соответствующий форме ввода логина и пароля и узнать у неё есть атрибут id = 'login' (атрибут id похож на атрибут class, но отличается уникальностью: существует ровно один элемент на странице с данным id).

Форма ввода логина и пароля

Извлечем эту форму в RoboBrowser.

In [75]:
form = q.get_form(id='login')

Нам естественно понадобятся логин и пароль от informatics. Чтобы не сохранять их в исходнике программы, я введу их с клавиатуры.

In [ ]:
login = input()
password = input()

Элемент form ведёт себя как словарь и вы можете передать ему ваши логин и пароль вот так:

In [76]:
form['username'] = login
form['password'] = password

Теперь посылаем заполненную форму браузеру.

In [77]:
q.submit_form(form)

Проверяем, что мы залогинились и наша фамилия или имя есть на странице

In [78]:
name = "Щуров"
if name in q.response.text:
    print("Okay, you are logged in")
Okay, you are logged in

Итак, мы залогинились и продемонстрировали, как совершать простейшие действия с помощью RoboBrowser. Дальше можно искать ссылки и переходить по ним, заполнять формы и т.д. В общем, RoboBrowser довольно удобен для простых задач, связанных с обращением к сайтам. Однако для дальнейшего нам потребуется инструмент помощнее…

Selenium

Давным-давно, в одной далёкой-далёкой галактике…

Когда-то давно трава была зеленой, деревья высокими, а Веб состоял из статических HTML-страниц. Его можно было только читать — ну и выкладывать новые HTML-страницы на сервер, если вы знали, как это делается. Потом появились разные интерактивные страницы типа форумов и первых блогов. Работали они примерно так: вы заходили на сайт, ваш браузер скачивал соответствующую страницу. Там можно было кликнуть по какой-то ссылке или заполнить какую-то форму (например, написать комментарий к посту). В ответ сервер генерировал новую HTML-страницу, браузер её снова загружал и т.д. При этом страница перезагружалась целиком, даже если там изменился всего один символ. Это было дико долго и неэффективно.

Потом появились новые технологии, которые позволили веб-странице обновляться «кусочками». Для этого в них стали встраивать помимо HTML-кода также код на языке JavaScript. В отличие от HTML, являющегося лишь языком разметки текста, язык JavaScript является полноценным императивным языком программирования (по своим возможностям он похож на Python) и с его помощью можно делать много разных вещей. В частности, в ответ на действие пользователя (например, клик по ссылке или кнопке) отправить какую-то информацию серверу, получить ответ и поменять в соответствии с этим ответом страничку, которая отображается в данный момент, не перезагружая её целиком. Благодаря этому, например, отправив комментарий в социальной сети мы тут же видим, как оно появилось, не перезагружая всю ленту целиком.

Но есть и тёмная сторона Силы. Современные веб-страницы бывает очень сложно обрабатывать как раз из-за того, что они генерируются динамически на стороне клиента (то есть пользователя). В частности, используемый нами RoboBrowser не умеет запускать JavaScript. А информация о посылках на informatics как раз именно им и генерируется — об этом свидетельствует тот факт, что после открытия соответствующей страницы её центральная часть отображается не сразу — сначала там крутится индикатор (в этот момент как раз JavaScript запрашивает информацию у сервера).

Selenium: дистанционное управление для браузера

Однако, не следует отчаиваться: нам поможет другой пакет, называемый Selenium. Он не запускает JavaScript сам, зато он умеет управлять браузерами, в том числе тем который уже установлен у вас.

Допустим, что нам надо скачать результаты наших посылок (на лекции скачивали результаты участников факультатива, но я не уверен, что они доступны для студентов). Здесь я буду скачивать результаты своих посылок, эти задачи аналогичны.

In [88]:
from selenium import webdriver

Откроем браузер с помощью Selenium. Для этого нужно чтобы у вас был установлен данный браузер. В моем случае это Firefox

In [89]:
browser = webdriver.Firefox()

Видим, что открылось окошко браузера. Перейдем на informatics

In [95]:
ref = 'http://informatics.mccme.ru'
browser.get(ref)

Найдем форму входа на сайт

In [96]:
form = browser.find_element_by_id('login')

Логика здесь примерно такая же, как в RoboBrowser (а у него она заимствована из Beautiful Soup), хотя названия методов различаются.

Найдем у этой формы элементы, отвечающие логину и паролю, и введём в них наши данные. Имейте в виду, что informatics может узнать вас и поле username может быть уже заполненным. Тогда нужно вводить только пароль. Следует отметить, что Selenium вводит данные в форму, эмулируя нажатия на кнопки, поэтому если в форме что-то уже записано, то дополнительные символы припишутся к уже существующим. Для безопасности мы на всякий случай очистим поле, прежде, чем что-то туда писать.

In [97]:
un = form.find_element_by_name('username')
un.clear() # на случай, если это поле уже заполнено, очистим его
un.send_keys(login)

pw = form.find_element_by_name('password')
pw.send_keys(password)

А теперь пошлем данные браузеру командой ниже.

In [98]:
form.submit()

Опять проверим, что теперь страница персонифицирована и в ней есть наше имя. Здесь browser.page_source — это HTML-код текущей страницы.

In [99]:
if name in browser.page_source:
    print("Okay, you are logged in!")
Okay, you are logged in!

Заметим, что мы можем управлять браузером, не только с помощью Python, но и вручную. Зайдите например, в «Мои посылки» вручную. Теперь из текущей страницы нужно извлечь информацию о посылках. Можно было бы использовать встроенные возможности Selenium по поиску HTML-элементов, но мы для простоты воспользуемся Beautiful Soup, передав ему browser.page_source.

Заметим, что browser.page_source — это не тот HTML-код, который был передан сервером, а тот, который мы построили на стороне клиента, в том числе, с помощью JavaScript. То есть это именно то, что нам нужно.

In [102]:
from bs4 import BeautifulSoup
bs = BeautifulSoup(browser.page_source)
/usr/local/lib/python3.5/site-packages/bs4/__init__.py:166: UserWarning: No parser was explicitly specified, so I'm using the best available HTML parser for this system ("lxml"). This usually isn't a problem, but if you run this code on another system, or in a different virtual environment, it may use a different parser and behave differently.

To get rid of this warning, change this:

 BeautifulSoup([your markup])

to this:

 BeautifulSoup([your markup], "lxml")

  markup_type=markup_type))

С помощью просмотра кода элемента в браузере мы можем узнать, что интересующая нас информация находится в теге <table> внутри тега <div> с id='Searchresult'. Извлечем её из bs. При этом результат bs('div', id = 'Searchresult') — это список (даже если результат только один). Поэтому нам надо взять первый элемент этого списка. Потом внутри div мы точно так же ищем table.

In [114]:
div = bs('div', id='Searchresult')[0]
# Можно было бы также использовать div = bs.find('div', id='Searchresult')

table = div('table')[0]

Напечатаем ячейки в первых строках этой таблицы

In [107]:
for row in table('tr')[:2]:
    # я печатаю только первые две строки
    for cell in row('td'):
        print(cell)
    print("---- Next cell ----")
<td>ID</td>
<td>Участник</td>
<td>Задача</td>
<td>Дата</td>
<td>Язык</td>
<td>Статус</td>
<td>Пройдено тестов</td>
<td>Баллы</td>
<td>Подробнее</td>
---- Next cell ----
<td>1758-36031</td>
<td><a href="/moodle/user/view.php?id=182842">Илья Щуров</a></td>
<td><a href="/moodle/mod/statements/view3.php?chapterid=3451&amp;run_id=1758r36031">3451. Корень степени 10.</a></td>
<td>2016-01-08 23:32:13</td>
<td>Python 3.3</td>
<td> <div aria-active-descendant="sbo691610707" aria-has-popup="true" aria-labelledby="" aria-owns="sbdd586279523" class="sb selectbox round_sb" id="sb592207805" role="listbox" style="width: 335px;"><div class="display round_sb" id="sbd91532714"><div class="text">Частичное решение</div><div class="arrow_btn"><span class="arrow"></span></div></div><ul aria-hidden="true" class="selectbox items round_sb" id="sbdd586279523" role="menu" style="max-height: 487.983px; position: absolute; visibility: visible; width: 334px; display: none; left: 339.1px; top: 206.017px;"><li aria-disabled="true" class="disabled first" id="sbo84723798" role="option"><div class="item"><div class="text">---</div></div></li><li aria-disabled="" id="sbo219628856" role="option"><div class="item"><div class="text">OK</div></div></li><li aria-disabled="" id="sbo646283843" role="option"><div class="item"><div class="text">Перетестировать</div></div></li><li aria-disabled="" id="sbo613943632" role="option"><div class="item"><div class="text">Зачтено/Принято</div></div></li><li aria-disabled="" id="sbo820750394" role="option"><div class="item"><div class="text">Ошибка оформления кода</div></div></li><li aria-disabled="" id="sbo82486357" role="option"><div class="item"><div class="text">Проигнорировано</div></div></li><li aria-disabled="" id="sbo833915987" role="option"><div class="item"><div class="text">Ошибка компиляции</div></div></li><li aria-disabled="" id="sbo308298062" role="option"><div class="item"><div class="text">Дисквалифицировано</div></div></li><li aria-disabled="" class="selected" id="sbo691610707" role="option"><div class="item"><div class="text">Частичное решение</div></div></li><li aria-disabled="" id="sbo81841931" role="option"><div class="item"><div class="text">Ожидает проверки</div></div></li><li aria-disabled="true" class="disabled" id="sbo154124644" role="option"><div class="item"><div class="text">Ошибка во время выполнения программы</div></div></li><li aria-disabled="true" class="disabled" id="sbo338287886" role="option"><div class="item"><div class="text">Превышено максимальное время работы</div></div></li><li aria-disabled="true" class="disabled" id="sbo222561433" role="option"><div class="item"><div class="text">Неправильный формат вывода</div></div></li><li aria-disabled="true" class="disabled" id="sbo209389642" role="option"><div class="item"><div class="text">Неправильный ответ</div></div></li><li aria-disabled="true" class="disabled" id="sbo983545951" role="option"><div class="item"><div class="text">Ошибка проверки,обратитесь к администраторам</div></div></li><li aria-disabled="true" class="disabled" id="sbo308282719" role="option"><div class="item"><div class="text">Превышение лимита памяти</div></div></li><li aria-disabled="true" class="disabled" id="sbo155768573" role="option"><div class="item"><div class="text">Security error</div></div></li><li aria-disabled="true" class="disabled" id="sbo127414586" role="option"><div class="item"><div class="text">Тестирование...</div></div></li><li aria-disabled="true" class="disabled last" id="sbo603528756" role="option"><div class="item"><div class="text">Компилирование...</div></div></li></ul></div><select class="round_sb has_sb" name="56901da4b1223" onchange="rejudgeRun(1758, 36031, this)" style="display: block;">
<option disabled="disabled" value="0">---</option>
<option value="r0">OK</option>
<option value="r99">Перетестировать</option>
<option value="r8">Зачтено/Принято</option>
<option value="r14">Ошибка оформления кода</option>
<option value="r9">Проигнорировано</option>
<option value="r1">Ошибка компиляции</option>
<option value="r10">Дисквалифицировано</option>
<option selected="selected" value="r7">Частичное решение</option>
<option value="r11">Ожидает проверки</option>
<option disabled="disabled" value="r2">Ошибка во время выполнения программы</option>
<option disabled="disabled" value="r3">Превышено максимальное время работы</option>
<option disabled="disabled" value="r4">Неправильный формат вывода</option>
<option disabled="disabled" value="r5">Неправильный ответ</option>
<option disabled="disabled" value="r6">Ошибка проверки,обратитесь к администраторам</option>
<option disabled="disabled" value="r12">Превышение лимита памяти</option>
<option disabled="disabled" value="r13">Security error</option>
<option disabled="disabled" value="r96">Тестирование...</option>
<option disabled="disabled" value="r98">Компилирование...</option>
</select>
</td>
<td>0</td>
<td>0</td>
<td><a href="/moodle/ajax/ajax_file.php?objectName=source&amp;contest_id=1758&amp;run_id=36031" onclick="loadSourceWindow(1758, 36031, '1');return false;">Подробнее</a></td>
---- Next cell ----

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

In [109]:
for row in table('tr'):
    cells = row('td')
    print(cells[3].string)
Дата
2016-01-08 23:32:13
2016-01-08 23:32:01
2016-01-08 23:31:48
2016-01-08 23:31:25
2015-10-06 02:39:28
2015-10-06 02:37:48
2015-10-06 02:36:33
2015-09-29 14:33:09
2015-09-22 14:39:18
2015-09-08 14:18:28

Если мы хотим выписать все элементы, то нам надо будет перейти на следующую страницу листинга. В браузере мы видим стрелочу >, ведущую к следующей странице результатов. Найдем элемент соответствующий этой стрелке.

In [110]:
a = browser.find_element_by_link_text('>')

К счастью, на странице это единственный элемент с таким текстом. Чтобы кликнуть по нему, сделаем следующее

In [111]:
a.click()

Видим, что загрузилась следующая страница, её можно обработать таким же образом, что и раньше.

Это можно повторять в цикле, и таким образом обработать все записи. Нужно только учитывать то, что Python не будет ждать загрузки страницы в браузере, прежде, чем выполнять следующие команды, поэтому, делая browser.page_source, мы рискуем загрузить старую страницу. Чтобы решить эту проблему, сделаем в Python искусственную паузу.

In [34]:
import time
time.sleep(1)

Эта команда сделает паузу на любое время в секундах (здесь на 1 секунду).

Отметим, что в Selenium есть команд «назад»…

In [37]:
browser.back()

…команда «вперёд»…

In [38]:
browser.forward()

…и «обновить»:

In [115]:
browser.refresh()

В общем, это полноценный браузер на дистанционном управлении. Теперь вы можете автоматизировать всё на свете!