Парсинг HTML страниц
LINEВведение
Большинство из нас ежедневно посещают различные сайты для получения информации. Как бы красиво не выглядел сайт внешне, в большинстве случаев нас интересует именно информация.
В этой статье мы познакомимся с парсингом HTML страниц на примере библиотеки python beautifulsoup4, разберем как устроен сам процесс и особенности автоматизированного сбора информации.
Как все устроено
Начнем с самых основ. Каждый раз, когда вы заходите на страницу через браузер, выполняются следующие действия. Браузер должен найти ip адрес сервера, установить соединение, скачать html файл (вместе со всеми остальными файлами , такими как css, javascrip, изображения, иконки и т.д.). Скачанный html файл хоть и имеет определенную структуру, но это просто текст, или просто поток байт.
Сам html(HyperText Markup Language) представляет язык для указания разметки страницы, используя набор тегов и атрибутов.
Как только браузер получит страницу и все нужные файлы, он должен отрисовать ее на экране. Для этого нужно рассчитать все параметры элементов (длину, отступы, цвет, расположение и т.д.). Проводить такие расчеты имея только простой текст, мягко говоря, не очень удобно. Поэтому браузер преобразует html в особую структуру, с которой работать намного удобнее. Т.к. html имеет иерархическую структуру и у каждого тега может быть только один прямой предок (родитель), наиболее удобной структурой будет дерево. Преобразовав все теги в узлы дерева, мы сможем быстро отвечать на вопросы "какие предки есть у вершины", "сколько предков у вершины", "кто родитель данной вершины" и т.д.
Возьмем небольшой фрагмент html для примера.

Получив такую строку, браузер должен первым делом провести лексический анализ, или токенизацию. Простыми словами, он должен сказать из каких структурных элементов (лексем) состоит этот текст (для компьютера весь файл - набор символов, он не различает открывающиеся/закрывающиеся теги). Для этих целей создают грамматику языка, т.е. правила корректного язык.
Для примера выше результатом лексического анализатора будет следующее. Мы разбили символы на 6 лексем.

Сделать это можно с помощью конечного автомата. У него будет несколько состояний. Конечный автомат проходит по строке, как только он находит символ "<", он меняет свое состояние на открытый тег. Дальше, встретив символ переходим в состояние название тега. Встретив символ ">" создаем токен "открывающий тег" и переходим в состояние данные. Если после символа "<" идет символ "/", значит создаем токен "данные" или "текст" и переходим в состояние закрывающего тега. Для отслеживания вложенности удобно использовать стек, т.к. первый открытый тег будет закрыт последним.
Полученный список токенов нужно передать в синтаксический анализатор. Он, на основе этих лексем, построит дерево разбора, или синтаксическое дерево.
Упрощенно это может выглядеть так. Сначала создадим объект Node, который будет представлять узел подобного дерева. Нам понадобится название тега, его содержимое, список вложенных тегов и ссылка на родителя. Пусть это будет объект следующего вида (написано на cи-подобном псевдоязыке)

Сначала зададим некоторый корень, от которого будем строить дерево. Дальше, если встречаем открытую скобку, создаем новый объект и добавляем в список children этот узел. То же самое делаем, если встречаем токен text. Если встречаем закрытую - переходим к родителю. В результате мы получим примерно следующее дерево.

Это был сильно упрощенный пример, для понимания основных идей. Язык, на котором будет построено дерево может быть любым. Созданное браузером дерево на основе html называется DOM (Document Object Model) деревом, т.е. модель документа в форме объектов. Взаимодействовать с объектами намного удобнее.
Мы упустили такие понятия как аттрибуты тегов, возможно стоит добавить словарь для всех классов для быстрого поиска и еще много чего. Но уже сейчас, имея дерево объектов, мы можем пройтись по нему, например поиском в глубину (статья про поиск в ширину / глубину тут) и найти, например, все ссылки (теги <a>).
Построение синтаксического дерева из html не самая легкая задача, отчасти по следующей причине. Если вы забудете закрыть тег, опечатаетесь или совершите еще какую-либо ошибку - браузер попытается ее исправить (сам закроет тег, добавит тег <html>, если вы его забыли и т.д.). Описать такую грамматику очень сложно. Прибавим к этому большой объем спецификации самого html(теги, атрибуты и их особенности).
Подробнее о html можно узнать здесь, в частности о формировании DOM можно найти здесь (это сайт сообщества WHATWG(Web Hypertext Application Technology Working Group), картинка взята с этого же сайта).

А здесь можно посмотреть, какое дерево разбора получится для указанного html.
Parser
Итак, что такое parser. В переводе с англ. parse - анализ, разбор. Суффикс "er" обычно указывает на то, что именно данный объект выполняет данную роль (play - player). Т.е. парсер - это тот, кто производит разбор.
Если мы можем скачать html страницу с сайта, то мы можем написать свой парсер (но лучше взять готовый), построить свое дерево объектов, искать информацию по этому дереву и выводить только нужные нам результаты(или использовать в дальнейшей обработке).
Парсинг html сайтов - это именно про это. Взять страницу, найти нужную информацию и выбросить все остальное.
Пример для разбора
На сайте некоторого института есть расписание экзаменов для каждого предмета, время и преподаватель(оформлен в виде ссылки), принимающий предмет (для простоты у нас будет только 3 предмета, не указаны номер аудитории, группы и т.д.).

Но все предметы здесь находятся в одном списке. Один из преподавателей обратился к вам с просьбой. Он попросил составить для него отдельный подобный список, в который включены только его предметы и даты экзаменов. В самом низу есть номер деканата института.
Так как вручную делать такое не хочется, мы напишем парсер для этой небольшой задачи.
HTML разметка будет следующей

В виде дерева DOM эту разметку можно представить следующим образом. Каждый такой прямоугольник - отдельный объект. Синей рамкой в ней обозначены текстовые узлы, черным узлы с тегами, зеленым цветом атрибуты узла, отдельным красным цветом выделен id одного узла, вместо 3-х желтых div показан только 1, т.к. остальные будут почти точной копией.

Представляя себе подобное дерево объектов работать с библиотеками для парсинга будет намного удобнее.
Python и beautifulsoup4
Для примера мы возьмем язык python и популярную библиотеку для парсинга beautifulsoup4. Это не так важно, какой язык и библиотеку выбрать. Они будут отличаться синтаксисом, набором методов, но основные идеи будут одинаковыми.
Первым делом импортируем BeautifulSoup, прочитаем html файл(который находится в файле "index.html") и создадим объект BeautifulSoup. В каком то смысле этот объект и есть наше дерево объектов.

Выбор парсера
При создании объекта BeautifulSoup в конструктор нужно передать 2 параметра. Первый - или строку html, или файл html. Второй параметр определяет сам парсер, т.е. как будет разобран документ.
Чтобы не переписывать документацию, оставлю просто ссылку с документации по выбору парсера. Там есть плюсы и минусы каждого, а также способы установки.
Узлы дерева
BeautifulSoup превращает html файл в дерево объектов python. В основном вам понадобятся 4 объекта:
- Tag - соответствует тегу html
- NavigableString - соответствует тексту внутри тега
- BeautifulSoup - соответствует всему дереву разбора
- Comment - соответствует комментарию в html (для этого примера был добавлен тег <p class="comment"><!--comment?--></p>)

Мы можем обращаться напрямую к тегам независимо от вложенности просто через нотацию точки. Например, если вызвать soup.p - то это означает примерно следующее: начни поиск от корня в глубину, и как только найдешь узел p - верни его. Вызов soup.html.body.ul... и т.д. означает следующее: начни от корня, найди первый встречающийся тег html, потом начни от него и найди первый тег body и т.д. до последнего указанного тега. В примере ниже soup.html.body.ul.li.div.p = soup.div.p (если вы попытаетесь найти от корня первый div, а потом первый p - то пройдете именно этим путем: html.body.ul.li.div.p). Если указанного тега нет в дереве - возвращается None.

String и name
С помощью string можно получить текст внутри тега. Если посмотреть на дерево объектов выше, то можно сказать что string возвращает объект text (точнее текстовое значение под text), а name возвращает название тега. Если у тега нет прямого наследника с тегом text (как у div), возвращается None.

Parent и parents
У каждого тега есть родитель и предки(кроме корня). Соответственно вызов метода parent находит прямого родителя, а parents находит всех родителей до корня. Для примера посмотрим на тег h3.

find() и find_all()
Грубо говоря, этих методов достаточно для большинства задач. По названию понятно, что первый из них ищет тег по какому-то параметру и возвращает первый найденный. А второй делает то же самое, но ищет все такие теги.
Так как find_all это пожалуй главный метод для парсинга, его мы рассмотрим особо подробно.
Посмотрим на сигнатуру метода find_all и рассмотрим каждый параметр.

- name - название тега, который мы ищем. Если тег с таким названием есть - вернется список, содержащий все такие теги. Если нет - вернется пустой список.

Можно передать в качестве параметра список, тогда будут найдены все теги из списка. Например, найдем все теги a и p

Если передать True то будет возвращен список со всеми объектами в дереве(показан вывод частично и только начала)

Одна из самых полезных возможностей - объявить функцию предикат, которая принимает тег и возвращает True/False, стоит ли его взять. Например, найдем теги у которых string не None и длина текста больше 10

- attrs - словарь, который будет использоваться при формировании выборки. Рассмотрим несколько примеров.
Сначала мы выведем все теги p, а затем только те, у которых id="tel". Атрибут attrs позволяет указать, какие у тега должны быть параметры и их значения.

Параметр может иметь значение True, что означает вернуть все теги, у которых есть данные параметр (независимо от значения). Заметим, что в данном примере мы не указали name, что означает все теги.

- recursive - булевская переменная, которая говорит искать теги рекурсивно или нет. Возьмем пример - тег body. У него есть 4 прямых потомка: h1, p, ul и еще один p. Но также есть теги p внутри ul. Если параметр recursive=True (по умолчанию) - мы найдем все теги p. Если False - только среди прямых потомков. Для лучшего понимания приведем деревья для recursive равного True и False соответственно.


- string - с помощью этого параметра можно искать строки вместо тегов. Например, мы знаем что где-то встречается текстовый узел "Васильев В.Г." (должно быть полное совпадение, добавление даже одного пробела изменит результат поиска). Но вы не знаете в каком теге находится этот текст. Для этого можно найти узел по значению текста, а затем найти родителя этого узла.

- limit - как понятно из названия, определяет лимит для списка найденных тегов.
Найдем список всех тегов p, их 5. Затем ограничим список первыми 3-мя значениями.

- **kwargs - любой именованный атрибут становится фильтром для атрибута тега. В целом смысл параметра, как и у словаря attrs. Например, выведем все теги, у которых есть id равный "tel". Затем выведем все теги, у которых есть хоть какой-то id. Затем все теги, у которых есть class равный "teacher_about".

Метод find() работает в целом таким же образом, но возвращает 1 элемент а не список. Сигнатура метода совпадает с find_all(), за исключением параметра limit(понятно почему).

Что есть еще
В библиотеке есть еще несколько полезных методов:
- find_next_sibling() и find_previous_sibling() - возвращает следующий/предыдущий элемент, расположенный на этом же уровне дерева.
Схематично это выглядит так (вызов для красного тега возвращает зеленый)

- find_next_siblings() и find_previous_siblings() - возвращает все следующие/предыдущие элементы, расположенный на этом же уровне дерева.
Схематично это выглядит так(вызов для красного тега возвращает зеленый)

- find_all_next и find_all_previous - возвращает список всех тегов, которые будут посещены после/до указанного. Например, если указанный тег li(оранжевый), то зеленым цветом обозначены все теги, которые будут посещены после li, а красным - до (т.е. уже посещенные)

- find_next() и find_previous() - возвращает первый следующий или уже посещенный элемент
- В BeautifulSoup есть возможность не только проходить по дереву, но и менять его. Однако это уже другая сторона библиотеки, о которой не будет рассказано в этой статье.
- Есть возможность объединять деревья или разбирать только часть дерева (класс SoupStrainer).
Более полную документацию по библиотеке можно найти тут
Парсинг сайтов
В примерах выше мы разбирали документ, который уже был. Если мы хотим разбирать сайт, нужно сначала скачать страницу html. Это можно сделать в пару строк с помощью библиотеки requests

В первой строку импортируем requests, затем создаем запрос и получаем его результат (requests.text). Дальше сохраняем его в файл index.html.
Если вы будете делать подобные запросы, то серверы могут относиться к вам недружелюбно. Все потому что каждый раз, когда подобный запрос делает браузер он добавляет в запрос различные заголовки и "рассказывает" в каком окружении он работает. Запрос без заголовков выглядит подозрительно. Чтобы имитировать работу реального браузера, заголовки можно добавить вручную, создав переменную headers типа словарь и передав при создании запроса.

Более подробно о библиотеке requests можно узнать здесь.
Как найти элемент
Предположим вы нашли нужный сайт, нужный текст, но теперь не совсем понятно как этот текст достать - где он находится, какой у него класс и т.д.
Для поиска нужного тега современные браузеры имеют очень удобные инструменты поиска. В google chrome нажмите клавишу F12 и увидите панель разработчика. Красным кружком обозначена кнопка, которая позволяет выделить элемент прямо на странице.

После выделения элемента он также выделится в разметке и можно найти полный путь к элементу, включая класс и идентификаторы элемента. Для примера выделим тег ссылки с текстом "Васильев В.Г.".

На вкладке Network есть раздел Headers, из которого можно взять заголовки для имитации реального браузера.
Решаем задачу из начала
Итак, нам нужно достать все предметы и даты экзаменов из страницы для "Васильев В.Г.". Напишем один из возможных вариантов. Для начала проанализируем страницу и поймем, что нужная нам информация лежит в теге div у которого есть тег a с классом "teacher_about" со значением "Васильев В.Г.". Напишем метод is_match, который будет возвращать только нужные div.
Дальше переберем все такие div. Заметим, что у каждого нужная нам информация лежит в теге h3, который имеет класс "subject". Внутри него находится название предмета и тег span, в котором находится дата. Оба значения можно получить вызвав h3.strings. Присваиваем результат в переменные subject и date, и просто выводим на экран.

Регулярные выражения
Для поиска нужных данных на сайте можно использовать регулярные выражения, но, как говорится "если у вас есть проблема и вы решили ее с помощью регулярных выражений - теперь у вас 2 проблемы". Если в 2-х словах - регулярные выражения для поиска и замены по образцу, когда вы не знаете точно чего хотите, но там должно быть "вот такое вот условие". Если вам нужно найти фразу, из которой вам известно начало и конец и она может быть в любом месте - регулярки то что вам нужно. Если вам нужно найти все ссылки с определенным классом, которые ведут на сайт с определенным доменом - написать регулярку будет не так просто, но библиотека для парсинга сделает то, что вам нужно за пару строк.
Говоря проще - парсить сайты регулярками можно, но можно не значит нужно. Для всего есть свой инструмент в зависимости от потребностей.
Когда парсинг не поможет
Наверно вы замечали следующую ситуацию на некоторых сайтах. Вы заходите (например на ютуб), внешне сайт загрузился, но вместо самих видео находятся заменители (placeholder), и через некоторое время загружаются сами видео, названия, описания и т.д. Такой же результат можно заметить в различных интернет магазинах с карточками товаров. Если вы попробуете скачать по-указанному url страницу html, то увидите, что там почти ничего нет (по крайне мере там нет нужных товаров). И вот почему.
Когда ваш браузер скачивает html, он также скачивает скрипты javascript, необходимые html. После загрузки страницы за всю страницу начинает отвечать именно javascript - он загружает нужные товары/предметы, после чего изменяет DOM и выводит измененную html на экран. Пользователь видит, что сайт работает, просто еще не все загрузилось. Для разработчиков сайта и компаний это возможность сократить время загрузки сайта, т.к. сначала загружается только "каркас" страницы и шаблоны а затем текстовые данные, которые просто заполняют шаблоны.
В этом случае скорее всего у сайта есть API - url адреса, которые вернут вам просто список данных (скорее всего в формате json или xml). В таком случае парсинг html вам вообще не нужен, нужно только узнать схему API.
Если API у сайта нет, или оно недоступно - можете посмотреть в сторону Selemiun или похожих технологий, которые дают возможность запускать javascript скрипты без интерфейса пользователя. Либо ждать пока откроется страница, загрузится весь контент и копировать его вручную.
Что дальше
Когда мы научились парсить одну страницу, можно написать робота, который будет сам переходить по ссылкам в поисках нужной информации. Для этого нужно просто сохранять ссылки на странице (возможно по некоторому фильтру) в очередь, а потом брать из этой очереди ссылки, переходить по ней и продолжать парсинг.
Вопросы морали
Можно встретить мнение, что парсинг это плохо с моральной/этической точки зрения. Но для того, чтобы ответить на вопрос плохо ли заниматься парсингом, нужно ответить на вопрос "что такое плохо, а что хорошо". Сам инструмент и идея не может быть плохим, он может использоваться во вред или ради пользы.
Поисковики, вроде google или яндекса также используют парсинг в своих системах, причем постоянно. Они загружают сайт, разбирают его, индексируют и сохраняют для последующей выдачи. Однако если вы хотите взять чужой блог, скачать все статьи и разместить их на своем сайте - наверно такой подход не будет хорошим применением парсинга. Напротив, если вы занимаетесь наукой/исследованием и вам нужны данные с какого то сайта, результаты которого могут улучшить жизнь людей - такой подход большинство поддержат.
Иногда при регистрации на сервисе в пользовательском соглашении можно встретить пункты по поводу запрета автоматизированного сбора информации. Стоит внимательней читать все, с чем вы соглашаетесь.
Заключение
Парсинг html сайтов позволяет доставать самую нужную информацию с сайта, отбрасывая все ненужное. С помощью парсинга можно придумать огромное количество полезных приложений, как для себя, так и других. Например, можно пойти на сайт с текстами песен, загрузить тексты песен и написать алгоритм, который разбирает и индексирует каждое слово в песне и место, где оно встречается. Затем сохранить все это в базе данных и отобразить все слова/фразы на названия песен. Получиться что-то вроде google для песен (конечно такие приложения уже есть).
Некоторые компании занимаются парсингом целенаправленно, например агрегаторы - они парсят сайты различных компаний, предоставляющих схожие услуги, а затем предлагают своим клиентам статистику по всем возможные вариантам. Например, какая компания продает один и тот же товар дешевле всех.