Текст лекции: Будылин Р.Я., Щуров И.В., НИУ ВШЭ
Данный notebook является конспектом лекции по курсу «Программирование на языке Python для сбора и анализа данных» (НИУ ВШЭ, 2015-16). Он распространяется на условиях лицензии Creative Commons Attribution-Share Alike 4.0. При использовании обязательно упоминание автора курса и аффилиации. При наличии технической возможности необходимо также указать активную гиперссылку на страницу курса. Фрагменты кода, включенные в этот notebook, публикуются как общественное достояние.
Другие материалы курса, включая конспекты и видеозаписи лекций, а также наборы задач, можно найти на странице курса.
В прошлый раз мы обсуждали работу с API. При этом для получения информации от API использовался формат XML. Помимо XML существует другой распространённый формат хранения и передачи структурированной информации, называющийся JSON. JSON расшифровывается как JavaScript Object Notation и изначально возник как подмножество языка JavaScript (пусть вас не вводит в заблуждение название, этот язык ничего не имеет общего с Java), используемое для описания объектов, но впоследствии стал использоваться и в других языках программирования, включая Python. Различные API могут поддерживать либо XML, либо JSON, либо и то, и другое, так что нам полезно научиться работать с обоими типами данных. Поэтому мы рассмотрим пример чтения данных из Википедии как в прошлый раз, но будем использовать формат JSON — на наше счастье, API MediaWiki это позволяет.
Напомним, что нашей задачей является получение списка всех статей из некоторой категории в Википедии. Вот так мы это делали в прошлый раз:
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
True
Как и в прошлый раз, мы взяли эти параметры из документации: 'action': 'query'
значит, что мы отправляем запрос, чтобы получить содержимое Википедии. Параметр list
отвечает на вопрос список чего мы бы хотели получить. В данном случае это categorymembers
— список элементов какой-то категории, cmtitle
— это название категории, список элементов которой мы хотим получить. 'format'
— это формат ответа, который в прошлый раз был xml
.
data = BeautifulSoup(g.text, features='xml')
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
:
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
True
Смотрим, что нам выдали по запросу. Это и есть JSON
r.text
'{"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. Так что нам не придётся использовать никакие дополнительные библиотеки.
q = r.json()
Видим, что q это словарь
q
{'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'}]}}
type(q)
dict
Содержательная информация хранится по ключу 'query'
. А уже внутри есть ключ 'categorymembers'
, значением которого является список всех категорий. Каждая категория отображается в виде словаря, записями которого являются разные параметры категории (например, 'title'
соответствует названию, а pageid
— внутреннему идентификатору в системе).
type(q['query']['categorymembers'])
list
Это список всех членов категории. Мы можем посмотреть на них с помощью цикла
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 ищет все теги с данным именем, а не только те, которые являются потомками первого уровня для данного тега. Таким образом, код выше можно было бы переписать более коротко:
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
позволяет работать с неким виртуальным браузером, который позволяет ходить по страничкам и получать их содержимое. На самом деле, этот браузер полностью эмулируется Python: фактически robobrowser
представляет собой надстройку над requests
и BeautifulSoup
, позволяющую несколько упростить типичные операции типа «найти ссылку и пройти по ней».
from robobrowser import RoboBrowser
Если вдруг Python ругается, что нет каких-то модулей, то сделайте pip install имя_модуля
в консоли.
q = RoboBrowser()
Мы создали виртуальный браузер.
ref = 'http://informatics.mccme.ru'
q.open(ref)
И сказали ему открыть ссылку. Мы можем посмотреть на html содержимое страницы командой ниже
# мне пришлось немного поколодовать, чтобы вывод получился не слишком длинным,
# но можно было написать просто
# 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.
form = q.get_form(id='login')
Нам естественно понадобятся логин и пароль от informatics. Чтобы не сохранять их в исходнике программы, я введу их с клавиатуры.
login = input()
password = input()
Элемент form
ведёт себя как словарь и вы можете передать ему ваши логин и пароль вот так:
form['username'] = login
form['password'] = password
Теперь посылаем заполненную форму браузеру.
q.submit_form(form)
Проверяем, что мы залогинились и наша фамилия или имя есть на странице
name = "Щуров"
if name in q.response.text:
print("Okay, you are logged in")
Okay, you are logged in
Итак, мы залогинились и продемонстрировали, как совершать простейшие действия с помощью RoboBrowser. Дальше можно искать ссылки и переходить по ним, заполнять формы и т.д. В общем, RoboBrowser довольно удобен для простых задач, связанных с обращением к сайтам. Однако для дальнейшего нам потребуется инструмент помощнее…
Когда-то давно трава была зеленой, деревья высокими, а Веб состоял из статических HTML-страниц. Его можно было только читать — ну и выкладывать новые HTML-страницы на сервер, если вы знали, как это делается. Потом появились разные интерактивные страницы типа форумов и первых блогов. Работали они примерно так: вы заходили на сайт, ваш браузер скачивал соответствующую страницу. Там можно было кликнуть по какой-то ссылке или заполнить какую-то форму (например, написать комментарий к посту). В ответ сервер генерировал новую HTML-страницу, браузер её снова загружал и т.д. При этом страница перезагружалась целиком, даже если там изменился всего один символ. Это было дико долго и неэффективно.
Потом появились новые технологии, которые позволили веб-странице обновляться «кусочками». Для этого в них стали встраивать помимо HTML-кода также код на языке JavaScript. В отличие от HTML, являющегося лишь языком разметки текста, язык JavaScript является полноценным императивным языком программирования (по своим возможностям он похож на Python) и с его помощью можно делать много разных вещей. В частности, в ответ на действие пользователя (например, клик по ссылке или кнопке) отправить какую-то информацию серверу, получить ответ и поменять в соответствии с этим ответом страничку, которая отображается в данный момент, не перезагружая её целиком. Благодаря этому, например, отправив комментарий в социальной сети мы тут же видим, как оно появилось, не перезагружая всю ленту целиком.
Но есть и тёмная сторона Силы. Современные веб-страницы бывает очень сложно обрабатывать как раз из-за того, что они генерируются динамически на стороне клиента (то есть пользователя). В частности, используемый нами RoboBrowser не умеет запускать JavaScript. А информация о посылках на informatics как раз именно им и генерируется — об этом свидетельствует тот факт, что после открытия соответствующей страницы её центральная часть отображается не сразу — сначала там крутится индикатор (в этот момент как раз JavaScript запрашивает информацию у сервера).
Однако, не следует отчаиваться: нам поможет другой пакет, называемый Selenium. Он не запускает JavaScript сам, зато он умеет управлять браузерами, в том числе тем который уже установлен у вас.
Допустим, что нам надо скачать результаты наших посылок (на лекции скачивали результаты участников факультатива, но я не уверен, что они доступны для студентов). Здесь я буду скачивать результаты своих посылок, эти задачи аналогичны.
from selenium import webdriver
Откроем браузер с помощью Selenium. Для этого нужно чтобы у вас был установлен данный браузер. В моем случае это Firefox
browser = webdriver.Firefox()
Видим, что открылось окошко браузера. Перейдем на informatics
ref = 'http://informatics.mccme.ru'
browser.get(ref)
Найдем форму входа на сайт
form = browser.find_element_by_id('login')
Логика здесь примерно такая же, как в RoboBrowser
(а у него она заимствована из Beautiful Soup), хотя названия методов различаются.
Найдем у этой формы элементы, отвечающие логину и паролю, и введём в них наши данные. Имейте в виду, что informatics может узнать вас и поле username может быть уже заполненным. Тогда нужно вводить только пароль. Следует отметить, что Selenium вводит данные в форму, эмулируя нажатия на кнопки, поэтому если в форме что-то уже записано, то дополнительные символы припишутся к уже существующим. Для безопасности мы на всякий случай очистим поле, прежде, чем что-то туда писать.
un = form.find_element_by_name('username')
un.clear() # на случай, если это поле уже заполнено, очистим его
un.send_keys(login)
pw = form.find_element_by_name('password')
pw.send_keys(password)
А теперь пошлем данные браузеру командой ниже.
form.submit()
Опять проверим, что теперь страница персонифицирована и в ней есть наше имя. Здесь browser.page_source
— это HTML-код текущей страницы.
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. То есть это именно то, что нам нужно.
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
.
div = bs('div', id='Searchresult')[0]
# Можно было бы также использовать div = bs.find('div', id='Searchresult')
table = div('table')[0]
Напечатаем ячейки в первых строках этой таблицы
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&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&contest_id=1758&run_id=36031" onclick="loadSourceWindow(1758, 36031, '1');return false;">Подробнее</a></td> ---- Next cell ----
Выглядит страшновато, но вообще-то видно, что вся интересующая нас информация как раз и находится в ячейках этой таблицы. Если нас интересует какая-то конкретная колонка, например дата и время отправки посылки, то её значения можно получить вот так:
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
Если мы хотим выписать все элементы, то нам надо будет перейти на следующую страницу листинга. В браузере мы видим стрелочу >
, ведущую к следующей странице результатов. Найдем элемент соответствующий этой стрелке.
a = browser.find_element_by_link_text('>')
К счастью, на странице это единственный элемент с таким текстом. Чтобы кликнуть по нему, сделаем следующее
a.click()
Видим, что загрузилась следующая страница, её можно обработать таким же образом, что и раньше.
Это можно повторять в цикле, и таким образом обработать все записи. Нужно только учитывать то, что Python не будет ждать загрузки страницы в браузере, прежде, чем выполнять следующие команды, поэтому, делая browser.page_source
, мы рискуем загрузить старую страницу. Чтобы решить эту проблему, сделаем в Python искусственную паузу.
import time
time.sleep(1)
Эта команда сделает паузу на любое время в секундах (здесь на 1 секунду).
Отметим, что в Selenium
есть команд «назад»…
browser.back()
…команда «вперёд»…
browser.forward()
…и «обновить»:
browser.refresh()
В общем, это полноценный браузер на дистанционном управлении. Теперь вы можете автоматизировать всё на свете!