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

Текст лекции: Щуров И.В., НИУ ВШЭ

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

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

Лекция №9: Поиск данных на HTML-страницах и работа с API с помощью XML

Поиск данных на HTML-странице

На предыдущей лекции мы познакомились с библиотекой Beautiful Soup и рассмотрели простейший пример обработки HTML с его помощью. Сейчас мы обсудим более сложные сценарии поиска данных на веб-страницах. Для примера возьмём статью из Википедии о романе М. А. Булгакова Мастер и Маргарита.

Заметим, что в Википедии встречаются ссылки двух типов: внутренние (на другие страницы Википедии) и внешние (на другие сайты), причём они различаются по оформлению — у внешних ссылок есть небольшая стрелочка. Например, мы хотим выбрать все внешние ссылки. Как это сделать?

Для того, чтобы браузер отображал внешние ссылки не так, как внутренние, разработчики Википедии используют так называемые css-классы (конечно, это касается не только Википедии — это вообще основной инструмент современного веба). Теги <a>, соответствующие внешним ссылкам, имеют специальный атрибут class, значение которого включает слово external. Именно по нему можно понять, что речь идёт о внешней ссылке. Это можно было бы увидеть, изучив исходный код страницы, но мы сделаем проще: воспользуемся встроенным инспектором кода в Firefox (в других браузерах есть аналоги — встроенные или в виде расширений).

Тег `<a>` с классом `external`

На скриншоте видно, что в исходном коде в атрибуте class тега <a> указана строчка "external text", а не просто "external" — дело в том, что теги могут иметь сразу несколько классов одновременно, и в данном случае external и text — это два класса данной ссылки. Но мы будем ориентироваться только на external.

Итак, мы хотим найти все ссылки с классом external. Это очень просто.

In [1]:
from bs4 import BeautifulSoup
import requests
In [8]:
url = "https://ru.wikipedia.org/w/index.php?oldid=75475510"
# Используем постоянную ссылку для воспроизводимости результатов
In [3]:
g = requests.get(url)
In [4]:
g.ok
Out[4]:
True
In [6]:
page = BeautifulSoup(g.text, "html.parser")
In [7]:
for link in page.findAll("a", class_='external'):
    print(link['href'])
//ru.wikipedia.org/w/index.php?title=%D0%A1%D0%BB%D1%83%D0%B6%D0%B5%D0%B1%D0%BD%D0%B0%D1%8F:%D0%96%D1%83%D1%80%D0%BD%D0%B0%D0%BB%D1%8B&type=review&page=%D0%9C%D0%B0%D1%81%D1%82%D0%B5%D1%80_%D0%B8_%D0%9C%D0%B0%D1%80%D0%B3%D0%B0%D1%80%D0%B8%D1%82%D0%B0
http://dombulgakova.ru/bulgakovskaya-biblioteka-2/lidiya-yanovskaya-o-romane-bulgakova-mas/
http://magazines.russ.ru/zvezda/2000/6/suhih.html
http://magazines.russ.ru/sib/2013/6/10d.html
http://mirknig.mobi/data/2012-12-06/1291088/Chudakova_Vremya_chitat_3_Ne_dlya_vzroslyih._Vremya_chitat_Polka_tretya.1291088.pdf
http://www.e-reading.club/chapter.php/39079/24/Zerkalov_-_Etika_Mihaila_Bulgakova.html
http://www.litmir.co/br/?b=65955&p=163
http://www.bulgakov.ru/m/morphiy/
http://www.russofile.ru/articles/article_67.php
http://feb-web.ru/feb/mayakovsky/texts/ms0/ms9/ms9-183-.htm
http://magazines.russ.ru/slovo/2008/58/ko11.html
http://magazines.russ.ru/nlo/2005/76/bra.html
http://www.rg.ru/2005/12/16/master.html
http://magazines.russ.ru/voplit/2009/4/ga8.html
http://magazines.russ.ru/znamia/2011/10/bo30.html
http://kinoart.ru/archive/2006/01/n1-article4
http://magazines.russ.ru/znamia/2007/1/bu17.html
http://izvestia.ru/news/499828
http://www.rg.ru/2011/09/08/mht.html
https://musicbrainz.org/work/2fa8ecec-2103-4aa7-97f1-2fbfd1c0607d
http://catalogue.bnf.fr/ark:/12148/cb11941979p
http://www.idref.fr/027361241
http://viaf.org/viaf/175580487

Как видно из примера выше, достаточно методу findAll() передать дополнительный именованный параметр class_ — обратите внимание на нижнее подчёркивание, без него получится синтаксическая ошибка, потому что слово class имеет специальный смысл в Python.

Классы и поиск по дереву

Решим теперь другую задачу: допустим, мы хотим найти все ссылки в разделе «Примечания», где находятся сноски к основному тексту. С помощью инспектора кода мы легко можем заметить, что весь этот раздел находится внутри тега <div> (этот тег описывает прямоугольные блоки, из которых состоят веб-страницы, и является основным тегом для современной веб-вёрстки), имеющем класс references-small.

In [13]:
divs = page.findAll('div', class_='references-small')
In [14]:
len(divs)
Out[14]:
1

Такой <div> оказался единственным на странице. Вот и хорошо. Найдём теперь все теги <a>, являющиеся потомками (возможно, отдалёнными) этого <div>'а.

In [15]:
div = page.findAll('div', class_='references-small')[0]
for link in div("a")[0:10]:
    print(link['href'])
#cite_ref-.D0.9B.D0.B5.D1.81.D1.81.D0.BA.D0.B8.D1.81.E2.80.941999.E2.80.94.E2.80.94213_1-0
#CITEREF.D0.9B.D0.B5.D1.81.D1.81.D0.BA.D0.B8.D1.811999
#cite_ref-.D0.9B.D0.BE.D1.81.D0.B5.D0.B2.E2.80.941993.E2.80.94.E2.80.94407_2-0
#cite_ref-.D0.9B.D0.BE.D1.81.D0.B5.D0.B2.E2.80.941993.E2.80.94.E2.80.94407_2-1
#CITEREF.D0.9B.D0.BE.D1.81.D0.B5.D0.B21993
#cite_ref-.D0.9B.D0.B5.D1.81.D1.81.D0.BA.D0.B8.D1.81.E2.80.941999.E2.80.94.E2.80.94214_3-0
#CITEREF.D0.9B.D0.B5.D1.81.D1.81.D0.BA.D0.B8.D1.811999
#cite_ref-.D0.A7.D1.83.D0.B4.D0.B0.D0.BA.D0.BE.D0.B2.D0.B0.E2.80.941988.E2.80.94.E2.80.94300_4-0
#CITEREF.D0.A7.D1.83.D0.B4.D0.B0.D0.BA.D0.BE.D0.B2.D0.B01988
#cite_ref-.D0.A7.D1.83.D0.B4.D0.B0.D0.BA.D0.BE.D0.B2.D0.B0.E2.80.941988.E2.80.94.E2.80.94301_5-0

Для экономии места я вывел только первые 10 ссылок. Это внутренние ссылки на другие фрагменты страницы, поэтому они начинаются с символа #. Легко увидеть, что мы получили то, что требовалось.

Некоторые итоги

Подведём некоторые итоги по поводу поиска информации в HTML-файлах:

  • Это всегда творческий процесс: все сайты разные и нет единого рецепта, как извлекать из них нужную информацию.
  • В первую очередь нужно посмотреть в исходник интересующей вас странички. Проще всего это делать с помощью инструментария веб-разработчика типа Firebug или встроенного инспектора кода в Firefox или аналогичных инструментов для других браузеров.
  • В HTML-дереве можно ориентироваться по названиям тегов, их классам, id'ам и другим свойствам.
  • Можно искать нужный элемент итеративно — сначала найти «большой» тег, который включает наш элемент, потом найти в нём элемент поменьше и т.д.

API и XML

Анализируя веб-страницы и извлекая из них информацию мы пытаемся написать программу, которая бы действовала как человек. Это бывает непросто. К счастью, всё чаще разнообразные сайты предлагают информацию, которую может легко обрабатывать не только человек, но и другая программа. Это называется API — application program interface. Обычный интерфейс — это способ взаимодействия человека с программой, а API — одной программы с другой. Например, вашего скрипта на Python с удалённым веб-сервером.

Для хранения веб-страниц, которые читают люди, используется язык HTML. Для хранения произвольных структурированных данных, которыми обмениваются между собой программы, используются другие языки — в частности, язык XML, похожий на HTML. Вернее было бы сказать, что XML это метаязык, то есть способ описания языков. В отличие от HTML, набор тегов в XML-документе может быть произвольным (и определяется разработчиком конкретного диалекта XML). Например, если бы мы хотели описать в виде XML некоторую студенческую группу, это могло бы выглядеть так:

<group>
    <number>134</number>
    <student>
        <firstname>Виталий</firstname>
        <lastname>Иванов</lastname>
    </student>
    <student>
        <firstname>Мария</firstname>
        <lastname>Петрова</lastname>
    </student>
</group>

Для обработки XML-файлов можно использовать тот же пакет Beautiful Soup, который мы уже использовали для работы с HTML. Единственное различие — нужно указать дополнительный параметр feautres="xml" при вызове функции BeautifulSoup — чтобы он не искал в документе HTML-теги.

In [17]:
group = """<group>
<number>134</number>
<student>
<firstname>Виталий</firstname>
<lastname>Иванов</lastname>
</student>
<student>
<firstname>Мария</firstname>
<lastname>Петрова</lastname>
</student>
</group>"""
In [20]:
obj = BeautifulSoup(group, features="xml")
print(obj.prettify())
<?xml version="1.0" encoding="utf-8"?>
<group>
 <number>
  134
 </number>
 <student>
  <firstname>
   Виталий
  </firstname>
  <lastname>
   Иванов
  </lastname>
 </student>
 <student>
  <firstname>
   Мария
  </firstname>
  <lastname>
   Петрова
  </lastname>
 </student>
</group>

Вот так мы можем найти в нашем XML-документе номер группы:

In [21]:
obj.group.number.string
Out[21]:
'134'

Это значит «в объекте obj найти тег group в нём найти тег number и выдать в виде строки то, что в нём содержится.

А вот так можно перечислить всех студентов:

In [30]:
for student in obj.group.findAll('student'):
    print(student.lastname.string, student.firstname.string)
Иванов Виталий
Петрова Мария

Получаем список статей из категории в Википедии

Допустим, нам потребовалось получить список всех статей из некоторой категории в Википедии. Мы могли бы открыть эту категорию в браузере и дальше действовать теми методами, которые обсуждались выше. Однако, на наше счастье разработчики Википедии сделали удобное API. Чтобы научиться с ним работать, придётся познакомиться с документацией (так будет с любым API), но это кажется сложным только в первый раз. Ну хорошо, в первые 10 раз. Или 20. Потом будет проще.

Итак, приступим. Взаимодействие с сервером при помощи API происходит с помощью отправки специальным образом сформированных запросов и получения ответа в одном из машинночитаемых форматов. Нас будет интересовать формат XML, хотя бывают и другие (позже мы познакомимся с JSONN). А вот такой запрос мы можем отправить:

Строка https://en.wikipedia.org/w/api.php (до знака вопроса) — это точка входа в API. Всё, что идёт после знака вопроса — это, собственно, запрос. Он представляет собой что-то вроде словаря и состоит из пар «ключ=значение», разделяемых амперсандом &. Некоторые символы приходится кодировать специальным образом.

Например, в адресе выше сказано, что мы хотим сделать запрос (action=query), перечислить элементы категории list=categorymembers, в качестве категории, которая нас интересует, указана Category:Physics (cmtitle=Category:Physics) и указаны некоторые другие параметры. Если кликнуть по этой ссылке, откроется примерно такая штука:

<?xml version="1.0"?>
<api batchcomplete="">
  <continue cmcontinue="2015-05-30 19:37:50|1653925" continue="-||" />
  <query>
    <categorymembers>
      <cm pageid="24293838" ns="0" title="Wigner rotation" />
      <cm pageid="48583145" ns="0" title="Northwest Nuclear Consortium" />
      <cm pageid="48407923" ns="0" title="Hume Feldman" />
      <cm pageid="48249441" ns="0" title="Phase Stretch Transform" />
      <cm pageid="47723069" ns="0" title="Epicatalysis" />
      <cm pageid="2237966" ns="14" title="Category:Surface science" />
      <cm pageid="2143601" ns="14" title="Category:Interaction" />
      <cm pageid="10844347" ns="14" title="Category:Physical systems" />
      <cm pageid="18726608" ns="14" title="Category:Physical quantities" />
      <cm pageid="22688097" ns="0" title="Branches of physics" />
    </categorymembers>
  </query>
</api>

Мы видим здесь разные теги, и видим, что нас интересуют теги <cm>, находящиеся внутри тега <categorymembers>.

Давайте сделаем соответствующий запрос с помощью Python. Для этого нам понадобится уже знакомый модуль requests.

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

g = requests.get(url, params=params)

Как видно, список параметров мы передаем в виде обычного словаря. Посмотрим, что получилось.

In [24]:
g.ok
Out[24]:
True

Всё хорошо. Теперь используем Beautiful Soup для обработки этого XML.

In [25]:
data = BeautifulSoup(g.text, features='xml')
In [26]:
print(data.prettify())
<?xml version="1.0" encoding="utf-8"?>
<api batchcomplete="">
 <continue cmcontinue="page|4d4f4445524e20504859534943530a4d4f4445524e2050485953494353|844186" continue="-||"/>
 <query>
  <categorymembers>
   <cm ns="0" pageid="22939" title="Physics"/>
   <cm ns="0" pageid="22688097" title="Branches of physics"/>
   <cm ns="0" pageid="3445246" title="Glossary of classical physics"/>
   <cm ns="0" pageid="24489" title="Outline of physics"/>
   <cm ns="100" pageid="1653925" title="Portal:Physics"/>
   <cm ns="0" pageid="151066" title="Classical physics"/>
   <cm ns="0" pageid="47723069" title="Epicatalysis"/>
   <cm ns="0" pageid="685311" title="Experimental physics"/>
   <cm ns="0" pageid="48407923" title="Hume Feldman"/>
   <cm ns="0" pageid="23581364" title="Microphysics"/>
  </categorymembers>
 </query>
</api>

Найдём все вхождения тега <cm> и выведем их атрибут title:

In [27]:
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

Можно было упростить поиск <cm>, не указывая «полный путь» к ним:

In [28]:
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

По умолчанию сервер вернул нам список из 10 элементов. Если мы хотим больше, нужно воспользоваться элементом continue — это своего рода гиперссылка на следующие 10 элементов.

In [29]:
data.find("continue")['cmcontinue']
Out[29]:
'page|4d4f4445524e20504859534943530a4d4f4445524e2050485953494353|844186'

Мне пришлось использовать метод find() вместо того, чтобы просто написать data.continue, потому что continue в Python имеет специальный смысл.

Теперь добавим cmcontinue в наш запрос и выполним его ещё раз:

In [30]:
params['cmcontinue'] = data.api("continue")[0]['cmcontinue']
In [31]:
g = requests.get(url, params=params)
data = BeautifulSoup(g.text, features='xml')
for cm in data.api.query.categorymembers("cm"):
    print(cm['title'])
Modern physics
Northwest Nuclear Consortium
Phase Stretch Transform
Statistical mechanics
Surface science
Wigner rotation
Category:Concepts in physics
Category:Physicists
Category:Applied and interdisciplinary physics
Category:Atomic, molecular, and optical physics

Мы получили следующие 10 элементов из категории. Продолжая таким образом, можно выкачать её даже целиком (правда, для этого потребуется много времени).

Аналогичным образом реализована работа с разнообразными другими API, имеющимися на разных сайтах. Где-то API является полностью открытым (как в Википедии), где-то вам потребуется зарегистрироваться и получить application id и какой-нибудь ключ для доступа к API, где-то попросят даже заплатить (например, автоматический поиск в Google стоит что-то вроде 5 долларов за 100 запросов). Есть API, которые позволяют только читать информацию, а бывают и такие, которые позволяют её править. Например, можно написать скрипт, который будет автоматически сохранять какую-то информацию в Google Spreadsheets. Всякий раз при использовании API вам придётся изучить его документацию, но это в любом случае проще, чем обрабатывать HTML-код. Иногда удаётся упростить доступ к API, используя специальные библиотеки.