Современный скрапинг веб-сайтов с помощью Python. 2-е межд. издание
Қосымшада ыңғайлырақҚосымшаны жүктеуге арналған QRRuStore · Samsung Galaxy Store
Huawei AppGallery · Xiaomi GetApps

автордың кітабын онлайн тегін оқу  Современный скрапинг веб-сайтов с помощью Python. 2-е межд. издание

 

Райан Митчелл
Современный скрапинг веб-сайтов с помощью Python. 2-е межд. издание
2021

Научный редактор С. Бычковский

Переводчик Е. Сандицкая

Литературный редактор Н. Хлебина

Художник В. Мостипан

Корректоры Н. Гринчик, Е. Павлович, Е. Рафалюк-Бузовская


 

Райан Митчелл

Современный скрапинг веб-сайтов с помощью Python. 2-е межд. издание . — СПб.: Питер, 2021.

 

ISBN 978-5-4461-1693-5

© ООО Издательство "Питер", 2021

 

Все права защищены. Никакая часть данной книги не может быть воспроизведена в какой бы то ни было форме без письменного разрешения владельцев авторских прав.

 

Введение

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

Работая инженером-программистом, я обнаружила, что веб-скрапинг — одна из немногих областей программирования, восхищающая как разработчиков, так и обычных людей. Умение легко написать простой бот, который бы собирал данные и передавал их через терминал или сохранял в базе данных, не перестает повергать в некий трепет от осознания своих возможностей, независимо от того, сколько раз вам приходилось делать это раньше.

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

Веб-скрапинг — обширная и быстро развивающаяся область, поэтому я постаралась представить здесь не только общие принципы, но и конкретные примеры, охватывающие практически все способы сбора данных, с которыми вы, вероятно, столкнетесь. В книге приводятся примеры кода, демонстриру­ющие эти принципы и позволяющие проверить их на практике. Сами примеры можно использовать и изменять как с указанием авторства, так и без него (хотя благодарности всегда приветствуются). Все примеры кода доступны на GitHub (http://www.pythonscraping.com/code/), где их можно просмотреть и скачать.

Что такое веб-скрапинг

Автоматизированный сбор данных в Интернете почти так же стар, как и сам Интернет. Несмотря на то что термин «веб-скрапинг» не является новым, еще несколько лет назад эту методику чаще называли анализом интерфейсных данных, интеллектуальным анализом данных, сбором веб-данных и т.п. Похоже, что наконец-то все пришли к единому мнению и предпочли называть это веб-скрапингом, поэтому я буду использовать данный термин на протяжении всей книги, хотя специализированные программы, которые просматривают несколько веб-страниц, я буду называть веб-краулерами, а программы, предназначенные для собственно веб-скрапинга, — ботами.

Теоретически веб-скрапинг — это сбор данных с использованием любых средств, за исключением программ, взаимодействующих с API. Обычно для этого пишут автоматизированную программу, которая обращается к веб-серверу, запрашивает данные (как правило, в формате HTML или в других форматах веб-страниц), а затем анализирует эти данные и извлекает оттуда полезную информацию.

На практике веб-скрапинг включает в себя широкий спектр методов и технологий программирования, таких как анализ данных, синтаксический анализ естественных языков и информационная безопасность. Именно потому, что эта область столь широка, в части I данной книги будут рассмотрены фундаментальные основы веб-скрапинга и веб-краулинга, а в части II — более углубленные темы. Я рекомендую внимательно изучить первую часть и погружаться в более специализированные разделы второй части по мере необходимости.

Почему это называется веб-скрапингом

Получать доступ к Интернету только через браузер — значит упускать массу возможностей. Браузеры (кроме прочего) удобны для выполнения скриптов JavaScript, вывода изображений и представления объектов в понятной для человека форме, однако веб-скраперы гораздо лучше справляются с быстрым сбором и обработкой больших объемов данных. Вместо того чтобы просматривать страницу за страницей на экране монитора, можно читать сразу целые базы данных, в которых хранятся тысячи и даже миллионы страниц.

Кроме того, веб-скраперы позволяют заглядывать в места, недоступные обычным поисковым системам. Так, при поиске в Google «самых дешевых рейсов в Бостон» вы получите кучу ссылок на рекламные объявления и популярные сайты поиска авиарейсов. Google знает только то, что сообщается на страницах оглавлений этих сайтов, а вовсе не точные результаты различных запросов, введенных в приложение поиска рейсов. Однако правильно построенный веб-скрапер способен создать график изменения стоимости перелета в Бостон во времени на разных сайтах и определить даты, когда можно купить самый выгодный билет.

Вы спросите: «Разве API не создаются специально для сбора данных?» (О том, что такое API, см. в главе 12.) Действительно, возможности API бывают просто фантастическими, если удастся найти тот из них, который соответствует вашим целям. API предназначены для построения удобного потока хорошо отформатированных данных из одной компьютерной программы в другую. Для многих типов данных, которые вы, возможно, захотите использовать, существуют готовые API — например, для постов Twitter или страниц «Википедии». Как правило, если существует подходящий API, то предпочтительнее использовать его вместо создания бота для получения тех же данных. Однако нужного API может не оказаться, или же этот API может не соответствовать вашим целям по нескольким причинам:

вам необходимо собирать относительно небольшие, ограниченные наборы данных с большого количества сайтов, у которых нет единого API;

• нужных данных сравнительно мало или они необычны и разработчик посчитал неоправданным создание для них специального API;

• источник не обладает инфраструктурой или техническими возможностями для создания API;

это ценные и/или защищенные данные, не предназначенные для широкого распространения.

Даже если API действительно существует, его возможности по объему и скорости обрабатываемых запросов, а также по типам или формату предоставляемых данных могут оказаться недостаточными для ваших целей.

Именно в таких случаях в дело вступает веб-скрапинг. За редким исключением, если данные доступны в браузере, то доступны и через скрипт Python. Данные, доступные в скрипте, можно сохранить в базе данных. А с сохраненными данными можно делать практически все что угодно.

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

Даже в мире искусства веб-скрапинг расширяет возможности для творчества. В 2006 году проект We Feel Fine («Мы прекрасно себя чувствуем») (http://wefeelfine.org/) Джонатана Харриса (Jonathan Harris) и Сэпа Камвара (Sep Kamvar) собрал из нескольких англоязычных блогов фразы, начинающиеся со слов I feel или I am feeling («я чувствую, я ощущаю»). В итоге получилась визуализация большого количества данных, описывающих то, что чувствовал мир день за днем, минуту за минутой.

Независимо от того, чем вы занимаетесь, веб-скрапинг почти всегда дает возможность сделать это более эффективно, повысить продуктивность или даже перейти на совершенно новый уровень.

Об этой книге

Данная книга — не только начальное пособие по веб-скрапингу, но и всеобъемлющее руководство по сбору, преобразованию и использованию данных из несовместимых источников. Однако, несмотря на то что здесь применяется язык программирования Python и изложены многие его основы, книгу не следует использовать для знакомства с этим языком.

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

Если вы ищете более подробный учебник по Python, то рекомендую Introducing Python Билла Любановича (Bill Lubanovic)1 — это хорошее, хоть и довольно объемное руководство. Тем, у кого не хватит на него времени, советую посмотреть видеоуроки Introduction to Python Джессики Маккеллар (Jessica McKellar) (издательство O’Reilly) (http://oreil.ly/2HOqSNM) — это отличный ресурс. Мне также понравилась книга Think Python моего бывшего профессора Аллена Дауни (Allen Downey) (издательство O’Reilly) (http://oreil.ly/2fjbT2F). Она особенно хороша для новичков в программировании. Это учебник не только по языку Python, но и по информатике вообще, а также по общим концепциям разработки ПО.

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

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

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

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

Условные обозначения

В этой книге используются следующие условные обозначения.

Курсив

Курсивом выделены новые термины и важные слова.

Моноширинный шрифт

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

Моноширинный жирный шрифт

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

Моноширинный курсивный шрифт

Показывает текст, который должен быть заменен значениями, введенными пользователем, или значениями, определяемыми контекстом.

Шрифт без засечек

Используется для обозначения URL, адресов электронной почты, названий кнопок, каталогов.

Этот рисунок указывает на совет или предложение.

Такой рисунок указывает на общее замечание.

Этот рисунок указывает на предупреждение.

Использование примеров кода

Дополнительный материал (примеры кода, упражнения и т.д.) можно скачать по адресу https://github.com/REMitchell/python-scraping.

Эта книга призвана помочь вам выполнять свою работу. Если какой-нибудь из приведенных примеров будет полезен для вас, то вы можете использовать его в своих программах и документации. Вам не нужно обращаться к нам за разрешением, если только вы не воспроизводите значительную часть кода. Так, для написания программы, в которой задействованы несколько фрагментов кода из данной книги, не требуется разрешения. А вот для продажи или распространения компакт-дисков с примерами из книг O’Reilly — требуется. Для ответа на  вопрос с помощью этой книги и примера кода разрешение не нужно. Однако на включение значительного количества примеров кода из книги в документацию вашего продукта требуется разрешение.

Мы ценим ссылки на эту книгу, но не требуем их. Как правило, такая ссылка включает в себя название, автора, издателя и ISBN. Например: «Митчелл Райан. Современный скрапинг веб-сайтов с помощью Python. — СПб.: Питер, 2021. — 978-5-4461-1693-5».

Если вы считаете, что использование вами примеров кода выходит за рамки правомерного применения или предоставленных выше разрешений, то обратитесь к нам по адресу permissions@oreilly.com.

К сожалению, бумажные книги трудно поддерживать в актуальном состоянии. В случае веб-скрапинга это создает дополнительную проблему, так как многие библиотеки и сайты, на которые ссылается данная книга и от которых часто зависит код, изменяются, из-за чего примеры кода могут перестать работать или приводить к неожиданным результатам. Если вы захотите выполнить примеры кода, то не копируйте их непосредственно из книги, а скачайте из репозитория GitHub. Мы — и я, и читатели этой книги, которые решили внести свой вклад и поделиться своими примерами (включая, возможно, вас!), — постараемся поддерживать хранилище в актуальном состоянии, вовремя внося необходимые изменения и примечания.

Помимо примеров кода, в этой книге часто приводятся команды терминала, демонстрирующие установку и запуск программного обеспечения. Как правило, эти команды предназначены для операционных систем на основе Linux, но большинство из них применимы и в Windows с правильно настроенной средой Python и установленным pip. В отношении тех случаев, когда это не так, я предоставила инструкции для всех основных операционных систем или внешние ссылки для пользователей Windows, чтобы облегчить задачу.

Благодарности

Лучшие продукты часто появляются благодаря многочисленным отзывам пользователей. Эта книга тоже никогда не появилась бы в сколько-нибудь полезном виде без помощи многих соавторов, руководителей и редакторов. Спасибо сотрудникам O’Reilly за их удивительную поддержку этого необычного начинания; моим друзьям и семье, которые давали советы и мирились с импровизированными чтениями; а также моим коллегам из HedgeServ, которым я, вероятно, теперь должна много часов работы.

В частности, спасибо Элисон Макдональд (Allyson MacDonald), Брайану Андерсону (Brian Anderson), Мигелю Гринбергу (Miguel Grinberg) и Эрику Ванвику (Eric VanWyk) за отзывы и советы — иногда резкие, но справедливые. Довольно много разделов и примеров кода появились в книге именно в результате их вдохновляющих предложений.

Благодарю Йела Шпехта (Yale Specht) за его безграничное терпение в течение четырех лет и двух изданий, за его поддержку, которая помогала мне продолжать этот проект, и обратную связь по стилистике в процессе написания. Без него я написала бы книгу вдвое быстрее, но она вряд ли была бы настолько полезной.

Наконец, спасибо Джиму Уолдо (Jim Waldo) — именно с него все это началось много лет назад, когда он прислал впечатлительной девочке-подростку диск с Linux и книгу The Art and Science of C.

От издательства

Ваши замечания, предложения, вопросы отправляйте по адресу comp@piter.com (издательство «Питер», компьютерная редакция).

Мы будем рады узнать ваше мнение!

На веб-сайте издательства www.piter.com вы найдете подробную информацию о наших книгах.

1 Любанович Б. Простой Python. Современный стиль программирования. 2-е изд. — СПб.: Питер, 2021.

Любанович Б. Простой Python. Современный стиль программирования. 2-е изд. — СПб.: Питер, 2021.

Если вы ищете более подробный учебник по Python, то рекомендую Introducing Python Билла Любановича (Bill Lubanovic)1 — это хорошее, хоть и довольно объемное руководство. Тем, у кого не хватит на него времени, советую посмотреть видеоуроки Introduction to Python Джессики Маккеллар (Jessica McKellar) (издательство O’Reilly) (http://oreil.ly/2HOqSNM) — это отличный ресурс. Мне также понравилась книга Think Python моего бывшего профессора Аллена Дауни (Allen Downey) (издательство O’Reilly) (http://oreil.ly/2fjbT2F). Она особенно хороша для новичков в программировании. Это учебник не только по языку Python, но и по информатике вообще, а также по общим концепциям разработки ПО.

Часть I. Разработка веб-скраперов

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

Честно говоря, веб-скрапинг — фантастическая отрасль: вложенные в нее относительно небольшие начальные инвестиции окупятся сторицей. Примерно 90 % проектов веб-скрапинга, которые вам встретятся, будут опираться на методы, описанные в следующих шести главах. Эта часть покажет, как обычные (хотя и технически подкованные) люди представляют себе работу веб-скраперов:

• извлечение HTML-данных из имени домена;

• анализ этих данных для получения требуемой информации;

• сохранение этой информации;

• возможен переход на другую страницу, чтобы повторить процедуру.

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

Глава 1. Ваш первый веб-скрапер

С первых шагов веб-скрапинга вы сразу начнете ценить все те мелкие услуги, которые нам оказывают браузеры. Без HTML-форматирования, стилей CSS, скриптов JavaScript и рендеринга изображений Интернет с непривычки может показаться слегка пугающим. Но в этой и следующей главах мы посмотрим, как форматировать и интерпретировать данные, не прибегая к помощи браузера.

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

Установка соединения

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

Однако веб-скрапинг требует сбросить покров с этого интерфейса — на уровне не только браузера (то, как он интерпретирует весь HTML-, CSS- и JavaScript-код), но иногда и сетевого подключения.

Чтобы дать вам представление об инфраструктуре, необходимой для получения информации в браузере, рассмотрим следующий пример. У Алисы есть веб-сервер, а у Боба — ПК, с которого он хочет подключиться к серверу Алисы. Когда одна машина собирается пообщаться с другой, происходит примерно следующий обмен данными.

1. Компьютер Боба посылает поток битов — единиц и нулей, которым соответствует высокое и низкое напряжение в кабеле. Из этих битов складывается некая информация, делимая на заголовок и тело. В заголовке содержится MAC-адрес ближайшего локального маршрутизатора и IP-адрес конечной точки — сервера Алисы. В тело включен запрос Боба к серверному приложению Алисы.

2. Локальный маршрутизатор Боба получает все эти нули и единицы и интерпретирует их как пакет, который передается с MAC-адреса Боба на IP-адрес Алисы. Маршрутизатор Боба ставит свой IP-адрес на пакете в графе «отправитель» и отправляет пакет через Интернет.

3. Пакет Боба проходит через несколько промежуточных серверов, которые по соответствующим кабелям передают пакет на сервер Алисы.

4. Сервер Алисы получает пакет по своему IP-адресу.

5. Сервер Алисы считывает из заголовка пакета номер порта-приемника и передает пакет соответствующему приложению веб-сервера. (Портом-приемником пакета в случае веб-приложений почти всегда является порт номер 80; это словно номер квартиры в адресе для пакетных данных, тогда как IP-адрес аналогичен названию улицы и номеру дома.)

6. Серверное приложение получает поток данных от серверного процессора. Эти данные содержат примерно такую информацию:

• вид запроса: GET;

• имя запрошенного файла: index.html.

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

Вуаля! Так работает Интернет.

В чем же при этом обмене данными участвует браузер? Ответ: ни в чем. В  действительности браузеры — сравнительно недавнее изобретение в истории Интернета: Nexus появился всего в 1990 году.

Конечно, браузер — полезное приложение, которое создает эти информационные пакеты, сообщает операционной системе об их отправке и интерпретирует данные, превращая их в красивые картинки, звуки, видео и текст. Однако браузер — это всего лишь код, который можно разбить на части, выделить основные компоненты, переписать, использовать повторно и приспособить для выполнения чего угодно. Браузер может дать команду процессору отправлять данные в приложение, поддерживающее беспроводной (или проводной) сетевой интерфейс, но то же самое можно сделать и на Python с помощью всего трех строк кода:

from urllib.request import urlopen

html = urlopen('http://pythonscraping.com/pages/page1.html')

print(html.read())

Для выполнения этого кода можно использовать оболочку iPython, которая размещена в репозитории GitHub для главы 1 (https://github.com/REMitchell/python-scraping/blob/master/Chapter01_BeginningToScrape.ipynb), или же сохранить код на компьютере в файле scrapetest.py и запустить его в окне терминала с помощью следующей команды:

$ python scrapetest.py

Обратите внимание: если на вашем компьютере, кроме Python 3.x, также установлен Python 2.x, и вы используете обе версии Python параллельно, то может потребоваться явно вызвать Python 3.x, выполнив команду следующим образом:

$ python3 scrapetest.py

По этой команде выводится полный HTML-код страницы page1, расположенной по адресу http://pythonscraping.com/pages/page1.html. Точнее, выводится HTML-файл page1.html, размещенный в каталоге <корневой веб-каталог>/pages на сервере с доменным именем http://pythonscraping.com.

Почему так важно представлять себе эти адреса как «файлы», а не как «страницы»? Большинство современных веб-страниц связано с множеством файлов ресурсов. Ими могут быть файлы изображений, скриптов JavaScript, стилей CSS и любой другой контент, на который ссылается запрашиваемая страница. Например, встретив тег <imgsrc="cuteKitten.jpg">, браузер знает: чтобы сгенерировать страницу для пользователя, нужно сделать еще один запрос к серверу и получить данные из файла cuteKitten.jpg.

Разумеется, у нашего скрипта на Python нет логики, позволяющей вернуться и запросить несколько файлов (пока что); он читает только тот HTML-файл, который мы запросили напрямую:

from urllib.request import urlopen

Эта строка делает именно то, что кажется на первый взгляд: находит модуль Python для запросов (в библиотеке urllib) и импортирует оттуда одну функцию — urlopen.

Библиотека urllib — это стандартная библиотека Python (другими словами, для запуска данного примера ничего не нужно устанавливать дополнительно), в которой содержатся функции для запроса данных через Интернет, обработки файлов cookie и даже изменения метаданных, таких как заголовки и пользовательский программный агент. Мы будем активно применять urllib в данной книге, так что я рекомендую вам прочитать раздел документации Python, касающийся этой библиотеки (https://docs.python.org/3/library/urllib.html).

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

Знакомство с BeautifulSoup

Прекрасный суп в столовой ждет.

Из миски жирный пар идет.

Не любит супа тот, кто глуп!

Прекрасный суп, вечерний суп!

Прекрасный суп, вечерний суп!

Алиса в Стране чудес. Издание 1958 г., пер. А. Оленича-Гнененко

Библиотека BeautifulSoup («Прекрасный суп») названа так в честь одноименного стихотворения Льюиса Кэрролла из книги «Алиса в Стране чудес». В книге это стихотворение поет Фальшивая Черепаха (пародия на популярное викторианское блюдо — фальшивый черепаховый суп, который варят не из черепахи, а из говядины).

Приложение BeautifulSoup стремится найти смысл в бессмысленном: помогает отформатировать и упорядочить «грязные» сетевые данные, исправляя ошибки в HTML-коде и создавая легко обходимые (traversable) объекты Python, являющиеся представлениями структур XML.

Установка BeautifulSoup

Поскольку BeautifulSoup не является стандартной библиотекой Python, ее необходимо установить. Если у вас уже есть опыт установки библиотек Python, то используйте любимый установщик и пропустите этот подраздел, сразу перейдя к следующему — «Работа с BeautifulSoup» на с. 29.

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

В этой книге мы будем использовать библиотеку BeautifulSoup 4 (также известную как BS4). Подробная инструкция по установке BeautifulSoup 4 размещена на сайте Crummy.com (http://www.crummy.com/software/BeautifulSoup/bs4/doc/); здесь же описан самый простой способ для Linux:

$ sudo apt-get install python-bs4

И для Mac:

$ sudo easy_install pip

Эта команда устанавливает менеджер пакетов Python pip.

Затем выполните следующую команду для установки библиотеки:

$ pip install beautifulsoup4

Еще раз подчеркну: если на вашем компьютере установлены версии Python 2.x и 3.x, то лучше явно вызвать python3:

$ python3 myScript.py

То же самое касается установки пакетов, иначе пакеты могут установиться не для Python 3.x, а в Python 2.x:

$ sudo python3 setup.py install

При использовании pip с целью установки пакета для Python 3.x также можно вызвать pip3:

$ pip3 install beautifulsoup4

Установка пакетов для Windows практически не отличается от данного процесса для Mac и Linux. Скачайте последнюю версию BeautifulSoup 4 со страницы скачивания библиотеки (http://www.crummy.com/software/BeautifulSoup/#Download), перейдите в каталог, в котором вы ее распаковали, и выполните следующую команду:

> python setup.py install

Готово! Теперь компьютер будет распознавать BeautifulSoup как библиотеку Python. Чтобы в этом убедиться, откройте терминал Python и импортируйте ее:

$ python

> from bs4 import BeautifulSoup

Импорт должен выполниться без ошибок.

Кроме того, для Windows есть программа-установщик менеджера пакетов pip (https://pypi.python.org/pypi/setuptools), с помощью которой можно легко устанавливать пакеты и управлять ими:

> pip install beautifulsoup4

Хранение библиотек непосредственно в виртуальных окружениях

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

Если библиотека Python устанавливается без виртуального окружения, то она устанавливается глобально. Обычно такую установку должен выполнять администратор или пользователь root, и тогда библиотека Python станет доступна для всех пользователей и проектов на данном компьютере. К счастью, создать виртуальное окружение легко:

$ virtualenv scrapingEnv

Эта команда создает новое окружение с именем scrapingEnv. Чтобы использовать данное окружение, его необходимо активировать:

$ cd scrapingEnv/

$ source bin/activate

После активации окружения его имя появится в командной строке, подсказывая, в каком именно окружении вы работаете. Все устанавливаемые библиотеки и запускаемые скрипты будут находиться только в этом виртуальном окружении.

В окружении scrapingEnv можно установить и использовать BeautifulSoup, например, так:

(scrapingEnv)ryan$ pip install beautifulsoup4

(scrapingEnv)ryan$ python

> from bs4 import BeautifulSoup

>

Выйти из окружения можно с помощью команды deactivate, после чего библиотеки, которые были установлены внутри виртуального окружения, станут недоступными:

(scrapingEnv)ryan$ deactivate

ryan$ python

> from bs4 import BeautifulSoup

Traceback (most recent call last):

File "<stdin>", line 1, in <module>

ImportError: No module named 'bs4'

Хранение всей библиотеки с разделением по проектам позволит легко заархивировать всю папку окружения и отправить ее кому-нибудь. Если на компьютере получателя установлена та же версия Python, что и у вас, то ваш код будет работать в его виртуальном окружении, не требуя дополнительной установки каких-либо библиотек.

Я не буду настаивать, чтобы вы применяли виртуальное окружение при выполнении всех примеров этой книги, однако помните: вы можете воспользоваться им в любой момент, просто заранее его активировав.

Работа с BeautifulSoup

Из всех объектов библиотеки BeautifulSoup чаще всего используется собственно, сам BeautifulSoup. Посмотрим, как он работает, изменив пример, приведенный в начале главы:

from urllib.request import urlopen

from bs4 import BeautifulSoup

 

html = urlopen('http://www.pythonscraping.com/pages/page1.html')

bs = BeautifulSoup(html.read(), 'html.parser')

print(bs.h1)

Результат выглядит так:

<h1>An Interesting Title</h1>

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

Как и в предыдущих примерах веб-скрапинга, мы импортируем функцию urlopen и вызываем html.read(), чтобы получить контент страницы в формате HTML. Помимо текстовой строки, BeautifulSoup также может принимать файловый объект, непосредственно возвращаемый функцией urlopen. Чтобы получить этот объект, не нужно вызывать функцию .read():

bs = BeautifulSoup(html, 'html.parser')

Здесь контент HTML-файла преобразуется в объект BeautifulSoup, имеющий следующую структуру:

html

<html><head>...</head><body>...</body></html>

head

<head><title>A Useful Page<title></head>

  — title

<title>A Useful Page</title>

body

<body><h1>An Int...</h1><div>Lorem ip...</div></body>

  — h1

<h1>An Interesting Title</h1>

  — div

<div>Lorem Ipsum dolor...</div>

Обратите внимание: тег h1, извлеченный из кода страницы, находится на втором уровне структуры объекта BeautifulSoup (html

body
h1). Однако, извлекая h1 из объекта, мы обращаемся к этому тегу напрямую:

bs.h1

На практике все следующие вызовы функций приведут к одинаковым результатам:

bs.html.body.h1

bs.body.h1

bs.html.h1

При создании объекта BeautifulSoup функции передаются два аргумента:

bs = BeautifulSoup(html.read(), 'html.parser')

Первый аргумент — это текст в формате HTML, на основе которого строится объект, а второй — синтаксический анализатор, который BeautifulSoup будет использовать для построения объекта. В большинстве случаев не имеет значения, какой именно синтаксический анализатор будет применяться.

Анализатор html.parser входит в состав Python 3 и не требует дополнительной настройки перед использованием. За редким исключением, в этой книге я  буду применять именно его.

Еще один популярный анализатор — lxml (http://lxml.de/parsing.html). Он устанавливается через pip:

$ pip3 install lxml

Для того чтобы использовать lxml в BeautifulSoup, нужно изменить имя синтаксического анализатора в знакомой нам строке:

bs = BeautifulSoup(html.read(), 'lxml')

Преимущество lxml, по сравнению с html.parser, состоит в том, что lxml в целом лучше справляется с «грязным» или искаженным HTML-кодом. Анализатор lxml прощает неточности и исправляет такие проблемы, как незакрытые и неправильно вложенные теги, а также отсутствующие теги head или body. Кроме того, lxml работает несколько быстрее, чем html.parser, хотя при веб-скрапинге скорость анализатора не всегда является преимуществом, поскольку почти всегда главное узкое место — скорость самого сетевого соединения.

Недостатками анализатора lxml является то, что его необходимо специально устанавливать и он зависит от сторонних C-библиотек. Это может вызвать проблемы портируемости; кроме того, html.parser проще в использовании.

Еще один популярный синтаксический анализатор HTML называется html5lib. Подобно lxml, он чрезвычайно лоялен к ошибкам и прилагает еще больше усилий к исправлению некорректного HTML-кода. Он также имеет внешние зависимости и работает медленнее, чем lxml и html.parser. Тем не менее выбор html5lib может быть оправданным при работе с «грязными» или написанными вручную HTML-страницами.

Чтобы использовать этот анализатор, нужно установить его и передать объекту BeautifulSoup строку html5lib:

bs = BeautifulSoup(html.read(), 'html5lib')

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

Надежное соединение и обработка исключений

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

Рассмотрим первую строку нашего скрапера, сразу после операторов импорта, и подумаем, как можно обрабатывать любые исключения, которые могли бы здесь возникнуть:

html = urlopen('http://www.pythonscraping.com/pages/page1.html')

Здесь могут случиться две основные неприятности:

на сервере нет такой страницы (или при ее получении произошла ошибка);

нет такого сервера.

В первой ситуации будет возвращена ошибка HTTP. Это может быть 404 Page Not Found, 500 Internal Server Error и т.п. Во всех таких случаях функция urlopen выдаст обобщенное исключение HTTPError. Его можно обработать следующим образом:

from urllib.request import urlopen

from urllib.error import HTTPError

 

try:

    html = urlopen('http://www.pythonscraping.com/pages/page1.html')

except HTTPError as e:

    print(e)

    # Вернуть ноль, прекратить работу или

    # выполнить еще какой-нибудь "план Б".

else:

    # Продолжить выполнение программы.

    # Примечание: если при перехвате исключений

    # программа прерывает работу или происходит возврат

    # из функции, то оператор else не нужен.

Теперь в случае возвращения кода HTTP-ошибки выводится сообщение о ней и остальная часть программы, которая находится в ветви else, не выполняется.

Если не найден весь сервер (например, сервер по адресу http://www.pythonscra­ping.com отключен или URL указан с ошибкой), то функция urlopen возвращает URLError. Эта ошибка говорит о том, что ни один из указанных серверов не доступен. Поскольку именно удаленный сервер отвечает за возвращение кодов состояния HTTP, ошибка HTTPError не может быть выдана и вместо нее следует обрабатывать более серьезную ошибку URLError. Для этого можно добавить в программу такую проверку:

from urllib.request import urlopen

from urllib.error import HTTPError

from urllib.error import URLError

 

try:

    html = urlopen('https://pythonscrapingthisurldoesnotexist.com')

except HTTPError as e:

    print(e)

except URLError as e:

    print('The server could not be found!')

else:

    print('It Worked!')

Конечно, даже если страница успешно получена с сервера, все равно остается проблема с ее контентом, который не всегда соответствует ожидаемому. Всякий раз, обращаясь к тегу в объекте BeautifulSoup, разумно добавить проверку того, существует ли этот тег. При попытке доступа к несуществующему тегу BeautifulSoup возвращает объект None. Проблема в том, что попытка обратиться к тегу самого объекта None приводит к возникновению ошибки AttributeError.

Следующая строка (в которой nonExistentTag — несуществующий тег, а не имя реальной функции BeautifulSoup) возвращает объект None:

print(bs.nonExistentTag)

Этот объект вполне доступен для обработки и проверки. Проблема возникает в том случае, если продолжать его использовать без проверки и попытаться вызвать для объекта None другую функцию:

print(bs.nonExistentTag.someTag)

Эта функция вернет исключение:

AttributeError: 'NoneType' object has no attribute 'someTag'

Как же застраховаться от этих ситуаций? Проще всего — явно проверить обе ситуации:

try:

    badContent = bs.nonExistingTag.anotherTag

except AttributeError as e:

    print('Tag was not found')

else:

    if badContent == None:

        print ('Tag was not found')

    else:

        print(badContent)

Такие проверка и обработка каждой ошибки поначалу могут показаться трудоемкими, однако если немного упорядочить код, то его станет проще писать (и, что еще важнее, гораздо проще читать). Вот, например, все тот же наш скрапер, написанный немного по-другому:

from urllib.request import urlopen

from urllib.error import HTTPError

from bs4 import BeautifulSoup

 

def getTitle(url):

    try:

        html = urlopen(url)

    except HTTPError as e:

        return None

    try:

        bs = BeautifulSoup(html.read(), 'html.parser')

        title = bs.body.h1

    except AttributeError as e:

        return None

    return title

 

title = getTitle('http://www.pythonscraping.com/pages/page1.html')

if title == None:

    print('Title could not be found')

else:

    print(title)

В этом примере мы создаем функцию getTitle, которая возвращает либо заголовок страницы, либо, если получить его не удалось, — объект None. Внутри getTitle мы, как в предыдущем примере, проверяем наличие HTTPError и инкапсулируем две строки BeautifulSoup внутри оператора try. Ошибка AttributeError может возникнуть в любой из этих строк (если сервер не найден, то html вернет объект None, а html.read() выдаст AttributeError). Фактически внутри оператора try можно разместить любое количество строк или вообще вызвать другую функцию, которая будет генерировать AttributeError в любой момент.

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

Глава 2. Углубленный синтаксический анализ HTML-кода

Однажды Микеланджело спросили, как ему удалось создать такой шедевр, как «Давид». Известен его ответ: «Это легко. Вы просто срезаете ту часть камня, которая не похожа на Давида».

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

Иногда молоток не требуется

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

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

bs.find_all('table')[4].find_all('tr')[2].find('td').find_all('div')[1].find('a')

Выглядит не слишком красиво. Но дело не только в эстетике: стоит администратору сайта внести малейшее изменение, и весь наш веб-скрапер сломается. А если разработчик сайта решит добавить еще одну таблицу или еще один столбец данных? Или разместит в верхней части страницы еще один компонент (с несколькими тегами div)? Показанная выше строка кода нестабильна: она опирается на то, что структура сайта никогда не изменится.

Что же делать?

Найдите ссылку Print This Page (Распечатать эту страницу) или, возможно, мобильную версию сайта с более удачным HTML-форматированием (по­дробнее о том, как выдать себя за мобильное устройство и получить мобильную версию сайта, см. в главе 14).

• Поищите информацию в файле JavaScript. Учтите, что для этого вам может понадобиться исследовать импортированные файлы JavaScript. Например, однажды я получила с сайта адреса улиц (вместе с широтой и долготой) в виде аккуратно отформатированного массива, заглянув в JavaScript-код встроенной карты Google, на которой были точно отмечены все адреса.

• Это больше касается заголовков, однако информация может находиться в URL самой страницы.

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

Когда речь идет о скрытых или плохо отформатированных данных, особенно важно не зарываться в код, не загонять себя в кроличью нору, из которой потом можно и не выбраться. Лучше сделайте глубокий вдох и подумайте: нет ли других вариантов?

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

Еще одна тарелка BeautifulSoup

В главе 1 мы кратко рассмотрели установку и запуск BeautifulSoup, а также выбор объектов по одному. В этом разделе обсудим поиск тегов по атрибутам, работу со списками тегов и навигацию по дереву синтаксического анализа.

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

<span class="green"></span>

А другие — так:

<span class="red"></span>

Веб-скраперы легко различают эти два тега по их классам; например, с по­мощью BeautifulSoup веб-скрапер может собрать весь красный текст, игнорируя зеленый. Поскольку CSS использует эти идентифицирующие атрибуты для соответствующего оформления сайтов, мы можем быть практически уверены в том, что на большинстве современных сайтов будет много атрибутов class и id.

Создадим пример веб-скрапера, который сканирует страницу, расположенную по адресу http://www.pythonscraping.com/pages/warandpeace.html.

На этой странице строки, в которых содержатся реплики персонажей, выделены красным цветом, а имена персонажей — зеленым. В следующем примере исходного кода страницы показаны теги span, которым присвоены соответствующие классы CSS:

<span class="red">Heavens! what a virulent attack!</span> replied

<span class="green">the prince</span>, not in the least disconcerted

by this reception.

С помощью программы, аналогичной той, которая была рассмотрена в главе 1, можно прочитать всю страницу и создать на ее основе объект BeautifulSoup:

from urllib.request import urlopen

from bs4 import BeautifulSoup

 

html = urlopen('http://www.pythonscraping.com/pages/page1.html')

bs = BeautifulSoup(html.read(), 'html.parser')

С помощью этого объекта BeautifulSoup можно вызвать функцию find_all и извлечь Python-список всех имен персонажей, полученных путем выбора текста из тегов <spanclass="green"></span> (find_all — очень гибкая функция, которую мы будем широко использовать в этой книге):

nameList = bs.find_all('span', {'class':'green'})

for name in nameList:

    print(name.get_text())

Результатом выполнения этого кода должен стать список всех персонажей «Войны и мира» в порядке их появления в тексте. Так что же здесь происходит? Раньше мы вызывали функцию bs.имяТега и получали первое появление тега на странице. Теперь мы вызываем функцию bs.find_all(имяТега,атрибутыТега), чтобы получить не только первый тег, а список всех тегов, присутствующих на странице.

Получив список персонажей, программа перебирает все имена в списке и использует функцию name.get_text(), чтобы очистить контент от тегов.

Когда использовать get_text(), а когда — сохранять теги

Функция .get_text() удаляет из документа, с которым вы работаете, все теги и возвращает строку, содержащую только текст в кодировке Unicode. Например, при работе с большим блоком текста, содержащим много гиперссылок, абзацев и других тегов, все эти теги будут удалены, останется только текст.

Учтите, что в объекте BeautifulSoup гораздо проще найти нужное, чем в текстовом фрагменте. Вызов .get_text() всегда должен быть последним, что вы делаете непосредственно перед выводом результата на экран, сохранением или манипулированием готовыми данными.

Как правило, следует как можно дольше сохранять структуру тегов документа.

Функции find() и find_all()

Функции BeautifulSoup find() и find_all() вы, скорее всего, будете использовать чаще других. С помощью этих функций можно легко фильтровать HTML-страницы, чтобы выделить списки нужных тегов или найти отдельный тег по всевозможным атрибутам.

Эти две функции очень похожи, о чем свидетельствуют их определения в документации BeautifulSoup:

find_all(tag, attributes, recursive, text, limit, keywords)

find(tag, attributes, recursive, text, keywords)

Скорее всего, в 95 % случаев вы будете использовать только первые два аргумента: tag и attributes. Однако мы подробно рассмотрим все аргументы.

Аргумент tag нам уже встречался; мы можем передать функции строку, содержащую имя тега, или даже Python-список имен тегов. Например, следующий код возвращает список всех тегов заголовков, встречающихся в документе2:

.find_all(['h1','h2','h3','h4','h5','h6'])

Аргумент attribute принимает Python-словарь атрибутов и ищет теги, которые содержат любой из этих атрибутов. Например, следующая функция ищет в HTML-документе теги span с классом greenилиred:

.find_all('span', {'class':{'green', 'red'}})

Аргумент recursive логический. Насколько глубоко вы хотите исследовать документ? Если recursive присвоено значение True, то функция find_all ищет теги, соответствующие заданным параметрам, в дочерних элементах и их потомках. Если же значение этого аргумента равно False, то функция будет просматривать только теги верхнего уровня документа. По умолчанию find_all работает рекурсивно (recurive имеет значение True); обычно лучше оставить все как есть, за исключением ситуаций, когда вы точно знаете, что делаете, и нужно обеспечить высокую производительность.

Аргумент text необычен из-за отношения не к свойствам тегов, а к их текстовому контенту. Так, чтобы узнать, сколько раз на странице встречается слово the prince, заключенное в теги, можно заменить функцию .find_all() из предыдущего примера на следующие строки:

nameList = bs.find_all(text='the prince')

print(len(nameList))

Результатом будет число 7.

Аргумент limit по понятным причинам используется только в методе find_all; функция find эквивалентна вызову find_all со значением limit, равным 1. Этот аргумент можно использовать в тех случаях, когда вы хотите извлечь только первые x элементов, присутствующих на странице. Однако следует учитывать, что вы получите первые элементы в порядке их появления на странице, и это вовсе не обязательно будут те элементы, которые вам нужны.

Аргумент keyword позволяет выбрать теги, содержащие определенный атрибут или набор атрибутов. Например:

title = bs.find_all(id='title', class_='text')

Этот код возвращает первый тег со словом text в атрибуте class_ и словом title в атрибуте id. Обратите внимание: по соглашению всем значениям атрибута id на странице следует быть уникальными. Поэтому на практике такая строка может быть не особенно полезна и должна быть эквивалентна следующей:

title = bs.find(id='title')

Аргумент keyword и атрибут class

В определенных ситуациях аргумент keyword может быть полезен. Однако технически, как свойство объекта BeautifulSoup, он избыточен. Помните: все, что можно сделать с помощью keyword, также можно выполнить методами, описанными далее в этой главе (см. разделы «Регулярные выражения» на с. 46 и «Лямбда-выражения» на с. 52).

Например, следующие две строки кода работают одинаково:

bs.find_all(id='text')

bs.find_all('', {'id':'text'})

Кроме того, используя keyword, вы периодически будете сталкиваться с проблемами, особенно при поиске элементов по атрибуту class, поскольку class в Python является защищенным ключевым словом. Другими словами, class — зарезервированное слово Python, которое нельзя применять в качестве имени переменной или аргумента (это не имеет никакого отношения к обсуждавшемуся ранее аргументу keyword функции BeautifulSoup.find_all()). Например, попытка выполнить следующий код повлечет синтаксическую ошибку из-за нестандартного использования слова class:

bs.find_all(class='green')

Вместо этого можно применить несколько неуклюжее решение BeautifulSoup с добавлением подчеркивания:

bs.find_all(class_='green')

Кроме того, можно заключить слово class в кавычки:

bs.find_all('', {'class':'green'})

В этот момент у вас может возникнуть вопрос: «Постойте, но ведь я уже знаю, как получить тег со списком атрибутов: нужно передать в функцию атрибуты в виде словарного списка!»

Напомню: передача списка тегов в .find_all() в виде списка атрибутов действует как фильтр «или» (функция выбирает тег, присутствующий в списке: тег1, тег2, тег3 и т.д.). Если список тегов достаточно длинный, то можно получить множество ненужных данных. Аргумент keyword позволяет добавить к этому списку дополнительный фильтр «и».

Другие объекты BeautifulSoup

До сих пор в этой книге нам встречались два типа объектов библиотеки BeautifulSoup:

объектыBeautifulSoup — экземпляры, которые в предыдущих примерах кода встречались в виде переменной bs;

• объектыTag — в виде списков или отдельных элементов, как результаты вызовов функций find и find_all для объекта BeautifulSoup или полученные при проходе по структуре объекта BeautifulSoup:

bs.div.h1

Однако в библиотеке есть еще два объекта, которые используются реже, но все же о них важно знать:

объектыNavigableString — служат для представления не самих тегов, а текста внутри тегов (некоторые функции принимают и создают не объекты тегов, а объекты NavigableString);

объектыComment — применяются для поиска HTML-комментариев, заключенных в теги комментариев, <!--например,так-->.

Из всей библиотеки BeautifulSoup вам придется иметь дело только с этими четырьмя объектами (на момент написания данной книги).

Навигация по деревьям

Функция find_all выполняет поиск тегов по их именам и атрибутам. Но как быть, если нужно найти тег по его расположению в документе? Здесь нам пригодится навигация по дереву. В главе 1 мы рассмотрели навигацию по дереву BeautifulSoup только в одном направлении:

bs.tag.subTag.anotherSubTag

Теперь рассмотрим навигацию по деревьям HTML-кода во всех направлениях: вверх, по горизонтали и диагонали. В качестве образца для веб-скрапинга мы будем использовать наш весьма сомнительный интернет-магазин, размещенный по адресу http://www.pythonscraping.com/pages/page3.html, показанный на рис. 2.1.

 

Рис. 2.1. Снимок экрана с сайта http://www.pythonscraping.com/pages/page3.html

HTML-код этой страницы, представленный в виде дерева (некоторые теги для краткости опущены), выглядит так:

HTML

— body

  — div.wrapper

     — h1

     — div.content

     — table#giftList

        — tr

           — th

           — th

           — th

           — th

        — tr.gift#gift1

           — td

           — td

              — span.excitingNote

           — td

           — td

             — img

        — ...другие строки таблицы...

     — div.footer

Мы будем использовать эту HTML-структуру в качестве примера в нескольких следующих разделах.

Работа с детьми и другими потомками

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

В BeautifulSoup, как и во многих других библиотеках, существует различие между детьми и потомками: как и в генеалогическом древе любого человека, дети всегда располагаются ровно на один уровень ниже родителей, тогда как потомки могут находиться на любом уровне дерева ниже родителя. Скажем, теги tr являются детьми тега table, а теги tr, th, td, img и span — потомками тега table (по крайней мере, в нашем примере). Все дети — потомки, но не все потомки — дети.

В целом функции BeautifulSoup всегда имеют дело с потомками тега, выбранного в данный момент. Например, функция bs.body.h1 выбирает первый тег h1, который является потомком тега body. Она не найдет теги, расположенные за пределами body.

Аналогично функция bs.div.find_all('img') найдет первый тег div в до­кументе, а затем извлечет список всех тегов img, которые являются потомками этого тега div.

Получить только тех потомков, которые являются детьми, можно с помощью тега .children:

from urllib.request import urlopen

from bs4 import BeautifulSoup

 

html = urlopen('http://www.pythonscraping.com/pages/page3.html')

bs = BeautifulSoup(html, 'html.parser')

 

for child in bs.find('table',{'id':'giftList'}).children:

    print(child)

Данный код выводит список всех строк таблицы giftList, в том числе начальную строку с заголовками столбцов. Если вместо функции children() в этом коде использовать функцию desndants(), то она найдет в таблице и выведет примерно два десятка тегов, включая img, span и отдельные теги td. Определенно имеет смысл различать детей и потомков!

Работа с братьями и сестрами

Функция next_siblings() библиотеки BeautifulSoup упрощает сбор данных из таблиц, особенно если в таблице есть заголовки:

from urllib.request import urlopen

from bs4 import BeautifulSoup

 

html = urlopen('http://www.pythonscraping.com/pages/page3.html')

bs = BeautifulSoup(html, 'html.parser')

 

for sibling in bs.find('table', {'id':'giftList'}).tr.next_siblings:

    print(sibling)

Этот код должен выводить все строки таблицы, кроме первой с заголовком. Почему пропускается строка заголовка? Потому что объект не может быть сиблингом сам себе. Каждый раз, когда составляется список сиблингов (братьев и сестер) объекта, сам объект не включается в этот список. Как следует из названия, данная функция выбирает только следующих по списку сиблингов. Например, если выбрать строку, расположенную в середине таблицы, и вызвать для нее функцию next_siblings, то функция вернет только тех сиблингов, которые идут в списке после данной строки. Таким образом, выбрав строку заголовка и вызвав функцию next_siblings, мы получим все строки таблицы, кроме самой строки заголовка.

Конкретизируйте свой выбор

Приведенный выше код будет работать ничуть не хуже, если выбрать первую строку таблицы как bs.table.tr или даже просто bs.tr. Однако в коде я не поленилась выразить все это в более длинной форме:

bs.find('table',{'id':'giftList'}).tr

Даже если на странице только одна таблица (или другой интересующий нас тег), можно легко что-то упустить. Кроме того, макеты страниц постоянно меняются. Элемент определенного типа, который когда-то стоял на странице первым, может в любой момент стать вторым или третьим. Чтобы сделать скрапер более надежным, лучше делать выбор тегов как можно более точным. По возможности используйте атрибуты тегов.

У функции next_siblings есть парная функция previous_siblings. Она часто бывает полезна, если в конце списка одноуровневых тегов, который вы хотели бы получить, есть легко выбираемый тег.

И конечно же, существуют функции next_sibling и previous_sibling, которые выполняют почти то же, что и next_siblings и previous_siblings, но только возвращают не список тегов, а лишь один тег.

Работа с родителями

При сборе данных со страниц вы, скорее всего, быстро поймете, что выбирать родительский тег необходимо реже, чем детей или сиблингов. Как правило, просмотр HTML-страницы с целью поиска данных мы начинаем с тегов верхнего уровня, после чего ищем способ углубиться в нужный фрагмент данных. Однако иногда встречаются странные ситуации, когда приходится использовать функции поиска родительских элементов .parent и .parents из библиотеки BeautifulSoup. Например:

from urllib.request import urlopen

from bs4 import BeautifulSoup

 

html = urlopen('http://www.pythonscraping.com/pages/page3.html')

bs = BeautifulSoup(html, 'html.parser')

print(bs.find('img', {'src':'../img/gifts/img1.jpg'})

      .parent.previous_sibling.get_text())

Этот код будет выводить цену объекта, изображенного на картинке ../img/gifts/img1.jpg (в данном случае цена составляет 15 долларов).

Как это работает? Ниже представлена древовидная структура фрагмента HTML-страницы, с которой мы работаем, и пошаговый алгоритм:

<tr>

— td

— td

— td  

  — "$15.00"

— td

  — <img src="../img/gifts/img1.jpg">

Выбираем тег изображения с атрибутом src="../img/gifts/img1.jpg".

Выбираем родителя этого тега (в данном случае тег td).

С помощью функции previous_sibling выбираем предыдущего сиблинга этого тега td (в данном случае тег td, который содержит цену продукта в долларах).

Выбираем текст, содержащийся в этом теге, — "$15.00".

Регулярные выражения

Как говорится в старой шутке по информатике, «допустим, есть некая проблема, которую мы хотим решить с помощью регулярных выражений. Теперь у нас две проблемы».

К сожалению, изучение регулярных выражений (часто сокращаемых до regex) часто сводится к пространным таблицам случайных символов, сочетания которых выглядят как абракадабра. Это отпугивает многих людей, а потом они решают рабочие задачи и пишут ненужные сложные функции поиска и фильтрации, хотя можно было бы обойтись всего одной строкой с регулярным выражением!

К счастью для вас, регулярные выражения не так уж трудно быстро освоить. Чтобы их изучить, достаточно рассмотреть всего несколько простых примеров и поэкспериментировать с ними.

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

Обратите внимание: я использовала выражение «регулярная строка». Что это такое? Это любая строка, которую можно построить с учетом последовательности линейных правил3 следующего вида.

1. Написать хотя бы одну букву a.

2. Добавить ровно пять букв b.

3. Добавить произвольное четное число букв c.

4. В конце поставить букву d или e.

Этим правилам соответствуют строки aaaabbbbbccccd, aabbbbbcce и т.д. (количество вариантов бесконечно).

Регулярные выражения — всего лишь краткий способ представления этих наборов правил. Например, регулярное выражение для описанного выше набора правил выглядит так:

aa*bbbbb(cc)*(d|e)

На первый взгляд эта строка смотрится жутковато, но она станет понятнее, если разбить ее на составляющие:

aa* — буква a, после которой стоит символ * (звездочка), означает «любое количество букв a, включая 0». Такая запись гарантирует, что буква a будет написана хотя бы один раз;

• bbbbb — ничего особенного, просто пять букв b подряд;

• (cc)* — любое количество чего угодно можно заключить в скобки. Поэтому для реализации правила о четном количестве букв c мы можем написать две буквы c, заключить их в скобки и поставить после них звездочку. Это значит, что в строке может присутствовать любое количество пар, состоящих из букв c (обратите внимание, что это также может означать 0 пар);

• (d|e) — вертикальная линия между двумя выражениями означает «то или это». В данном случае мы говорим «добавить d или e». Таким образом мы гарантируем, что в строку добавится ровно один из этих двух символов.

Эксперименты с регулярными выражениями

Осваивая регулярные выражения, очень важно поиграть с ними и понять, как они работают. Если вам не хочется ради пары строк открывать редактор кода и запускать программу, то проверить, работает ли регулярное выражение должным образом, можно на одном из специальных сайтов, например Regex Pal (http://regexpal.com/).

В табл. 2.1 приводятся наиболее часто используемые символы регулярных выражений с краткими пояснениями и примерами. Этот список ни в коем случае не претендует на полноту. Кроме того, как уже упоминалось, вы можете столкнуться с незначительными различиями, в зависимости от языка. Однако приведенные здесь 12 символов наиболее часто встречаются в регулярных выражениях Python и могут служить для поиска и сбора практически любых строк.

Таблица 2.1. Часто используемые символы регулярных выражений

Символ (-ы)

Значение

Пример

Соответствующие строки

*

Предыдущий символ, подвыражение или символ в скобках повторяется ноль или более раз

a*b*

aaaaaaaa, aaabbbbb, bbbbbb

+

Предыдущий символ, подвыражение или символ в скобках повторяется один раз или более

a+b+

aaaaaaaab, aaabbbbb, abbbbbb

[]

Любой символ в скобках (может читаться как «выберите любое количество этих предметов»)

[A–Z]*

APPLE, CAPITALS, QWERTY

()

Сгруппированное подвыражение (в «порядке операций» над регулярными выражениями выполняется в первую очередь)

(a*b)*

aaabaab, abaaab, ababaaaaab

{m, n}

Предыдущий символ, подвыражение или символ в скобках повторяется от m до n раз (включительно)

a{2,3}b{2,3}

aabbb, aaabbb, aabb

[^]

Любой одиночный символ, которого нет в скобках

[^A–Z]*

apple, lowercase, qwerty

|

Любой символ, строка символов или подвыражение из тех, что разделены знаком | (обратите внимание: это не заглавная буква i, а вертикальная черта, также называемая прямым слешем или символом конвейеризации)

b(a|i|e)d

bad, bid, bed

.

Любой одиночный символ (буква, цифра, пробел и т.д.)

b.d

bad, bzd, b$d, b d

^

Указывает на то, что символ или подвыражение должны находиться в начале строки

^a

apple, asdf, a

\

Экранирующий символ (позволяет использовать специальные символы в их буквальном значении)

\^ \| \\

^ | \

$

Часто ставится в конце регулярного выражения и означает «до конца строки». Без этого в конце любого регулярного выражения де-факто стоит «.*», что позволяет принимать строки, в которых совпадает только первая часть. Данный знак можно считать аналогом символа ^

[A–Z]*

[a–z]*$

ABCabc, zzzyx, Bob

?!

«Не содержит». Эта странная пара символов, непосредственно предшествующая символу (или регулярному выражению), указывает на то, что данный символ не должен присутствовать в этом месте строки. Иногда его сложно использовать; в конце концов, символ может находиться в другой части строки. Если вы хотите совсем исключить данный символ, то задействуйте это выражение в сочетании с ^ и $ на обоих концах строки

^((?!

[A–Z]).)*$

No-caps-here, $ymb0ls a4e f!ne

Один из классических примеров регулярных выражений — распознавание адресов электронной почты. Точные правила составления адресов электронной почты слегка различаются на разных почтовых серверах, однако можно составить несколько общих правил. Регулярное выражение для каждого из этих правил показано во втором столбце табл. 2.2.

Таблица 2.2. Точные правила составления адресов электронной почты и регулярные выражения для каждого из них

Правило 1

[A–Za–z0–9._+]+

Первая часть адреса электронной почты обязательно содержит хотя бы один элемент из следующего списка: заглавные буквы, строчные буквы, цифры от 0 до 9, точки (.), знаки «плюс» (+) или подчеркивания (_)

Регулярные выражения очень красиво сокращаются. Например, A–Z означает любую заглавную букву от A до Z. Поместив все возможные последовательности и символы в квадратные скобки (не путать с круглыми), мы как бы говорим: «Символ может удовлетворять любому из условий, перечисленных в скобках». Обратите также внимание: знак + означает, что символы могут встречаться произвольное количество раз, но не менее одного

Правило 2

@

После этого в адресе электронной почты должен стоять символ @

Все очень просто: в середине адреса должен стоять символ @ и встречаться ровно один раз

Правило 3

[A–Za–z]+

Адрес электронной почты должен содержать хотя бы одну букву, заглавную или строчную

В первой части имени домена, после символа @, допустимы только буквы, и их должно быть не менее одной

Правило 4

.

Потом идет точка (.)

Перед доменом верхнего уровня должна стоять точка (.)

Правило 5

(com|org|edu|net)

Наконец, адрес электронной почты заканчивается на com, org, edu или net (в действительности существует еще много доменов верхнего уровня, но для нашего примера этих четырех должно быть достаточно)

Здесь перечислены возможные последовательности букв, которые могут стоять после точки во второй части адреса электронной почты

Объединив все эти правила, получим следующее регулярное выражение:

[A-Za-z0-9._+]+@[A-Za-z]+.(com|org|edu|net)

При попытке написать регулярное выражение с нуля лучше сначала составить список шагов, которые бы четко описывали, какой должна быть ваша строка. Обратите внимание на граничные случаи. Например, если вы описываете номера телефонов, то учитываете ли коды стран и добавочные номера?

Регулярные — не значит неизменные!

Стандартная версия регулярных выражений (описанная в данной книге и используемая в Python и BeautifulSoup) основана на синтаксисе Perl. Этот или похожий синтаксис применяется в большинстве современных языков программирования. Однако следует помнить, что при использовании регулярных выражений на другом языке вы можете столкнуться с проблемами.

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

Регулярные выражения и BeautifulSoup

Если предыдущий раздел, посвященный регулярным выражениям, показался вам несколько оторванным от темы этой книги, то теперь мы восстановим связь. В отношении веб-скрапинга BeautifulSoup и регулярные выражения идут рука об руку. В сущности, функция, принимающая строку в качестве аргумента (например, find(id="идентификаторТега")), скорее всего, будет принимать и регулярное выражение.

Рассмотрим несколько примеров, проверив страницу по адресу http://www.python­scraping.com/pages/page3.html.

Обратите внимание: на этом сайте есть много изображений товаров, представленных в таком виде:

<img src="../img/gifts/img3.jpg">

Если мы хотим собрать URL всех изображений товаров, то на первый взгляд решение может показаться довольно простым: достаточно выбрать все теги изображений с помощью функции .find_all("img"), верно? Не совсем. Кроме очевидных «лишних» изображений (например, логотипов), на современных сайтах часто встречаются скрытые и пустые изображения, используемые вместо пробелов и для выравнивания элементов, а также другие случайные теги изображений, о которых вы, возможно, не знаете. Определенно нельзя рассчитывать на то, что все изображения на странице являются только изображениями товаров.

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

Решение состоит в поиске чего-то идентифицирующего сам тег. В данном случае можно поискать путь к файлам изображений товаров:

from urllib.request import urlopen

from bs4 import BeautifulSoup

import re

 

html = urlopen('http://www.pythonscraping.com/pages/page3.html')

bs = BeautifulSoup(html, 'html.parser')

images = bs.find_all('img',

    {'src':re.compile('..\/img\/gifts/img.*.jpg')})

for image in images:

    print(image['src'])

Этот код выводит только те относительные пути к изображениям, которые начинаются с ../img/gifts/img и заканчиваются на .jpg:

../img/gifts/img1.jpg

../img/gifts/img2.jpg

../img/gifts/img3.jpg

../img/gifts/img4.jpg

../img/gifts/img6.jpg

Регулярное выражение может использоваться как аргумент в выражении, написанном на BeautifulSoup, что обеспечивает большую гибкость при поиске нужных элементов.

Доступ к атрибутам

До сих пор мы исследовали способы доступа к тегам и их контенту и способы их фильтрации. Однако часто при веб-скрапинге нас интересует не содержимое тега, а его атрибуты. Это особенно полезно для таких тегов, как a, в атрибуте href содержащих URL, на которые ссылаются эти теги, или же тегов img, в атрибуте src содержащих ссылки на целевые изображения.

Python позволяет автоматически получить список атрибутов для объекта тега, вызвав следующую функцию:

myTag.attrs

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

myImgTag.attrs['src']

Лямбда-выражения

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

По сути, лямбда-выражение — это функция, которая передается в другую функцию как переменная; вместо того чтобы определять функцию как f(x, y), мы можем определить ее как f(g(x), y) или даже как f(g(x), h(x)).

BeautifulSoup позволяет передавать в функцию find_all функции определенных типов в качестве параметров.

Единственное ограничение состоит в том, что эти функции должны принимать в качестве аргумента объект тега и возвращать логическое значение. В этой функции BeautifulSoup оценивает каждый переданный ей объект тега; теги, имеющие значение True, возвращаются, а остальные отбрасываются.

Например, следующая функция возвращает все теги, имеющие ровно два атрибута:

bs.find_all(lambda tag: len(tag.attrs) == 2)

Здесь в качестве аргумента передается функция len(tag.attrs)==2. Когда она равна True, функция find_all станет возвращать соответствующий тег. Другими словами, будут найдены все теги с двумя атрибутами, например следующие:

<div class="body" id="content"></div>

<span style="color:red" class="title"></span>

Лямбда-функции настолько полезны, что ими даже можно заменять существующие функции BeautifulSoup:

bs.find_all(lambda tag: tag.get_text() ==

    'Or maybe he\'s only resting?')

То же самое можно выполнить и без лямбда-функции:

bs.find_all('', text='Or maybe he\'s only resting?')

Однако если вы помните синтаксис лямбда-функции и знаете, как получить доступ к свойствам тега, то вам, возможно, больше никогда не понадобится вспоминать остальной синтаксис BeautifulSoup!

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

2 Для получения списка всех тегов h<уровень>, имеющихся в документе, существуют более лаконичные варианты кода. Есть и другие способы решения таких задач, которые мы рассмотрим в разделе, посвященном регулярным выражениям.

3 Вы спросите: «А существуют ли “нерегулярные” выражения?» Да, существуют, но выходят за рамки этой книги. Нерегулярные выражения описывают такие строки, как «написать простое число букв a, а после них — ровно вдвое большее число букв b» или «написать палиндром». Строки этого типа невозможно описать с помощью регулярных выражений. К счастью, мне никогда не встречались ситуации, в которых веб-скрапер должен был бы находить подобные строки.

Аргумент tag нам уже встречался; мы можем передать функции строку, содержащую имя тега, или даже Python-список имен тегов. Например, следующий код возвращает список всех тегов заголовков, встречающихся в документе2:

Вы спросите: «А существуют ли “нерегулярные” выражения?» Да, существуют, но выходят за рамки этой книги. Нерегулярные выражения описывают такие строки, как «написать простое число букв a, а после них — ровно вдвое большее число букв b» или «написать палиндром». Строки этого типа невозможно описать с помощью регулярных выражений. К счастью, мне никогда не встречались ситуации, в которых веб-скрапер должен был бы находить подобные строки.

Для получения списка всех тегов h<уровень>, имеющихся в документе, существуют более лаконичные варианты кода. Есть и другие способы решения таких задач, которые мы рассмотрим в разделе, посвященном регулярным выражениям.

Обратите внимание: я использовала выражение «регулярная строка». Что это такое? Это любая строка, которую можно построить с учетом последовательности линейных правил3 следующего вида.

Глава 3. Разработка веб-краулеров

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

Веб-краулеры называются так потому, что они «ползают» (crawl) по Всемирной паутине и собирают данные с веб-страниц. В основе их работы лежит рекурсия. Веб-краулер получает контент страницы по ее URL, исследует эту страницу, находит URL другой страницы, извлекает содержимое этой другой страницы и далее до бесконечности.

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

Проход отдельного домена

Даже если вам еще не приходилось слышать об игре «Шесть шагов по “Википедии”», вы наверняка знаете о ее предшественнице — «Шесть шагов до Кевина Бейкона». В обеих играх цель состоит в том, чтобы установить взаимосвязь между двумя мало связанными элементами (в первом случае — между статьями «Википедии», а во втором — между актерами, сыгравшими в одном фильме) и построить цепь, содержащую не более шести звеньев (включая начальный и конечный элементы).

Например, Эрик Айдл (Eric Idle) снялся в фильме «Дадли Справедливый» (Dudley Do-Right) с Бренданом Фрейзером (Brendan Fraser), который, в свою очередь, снялся с Кевином Бейконом (Kevin Bacon) в «Воздухе, которым я дышу» (The Air I Breathe)4. В этом случае цепь от Эрика Айдла до Кевина Бейкона состоит всего из трех элементов.

В этом разделе мы начнем разработку проекта, который позволит строить цепи для «Шести шагов по “Википедии”»: например, начав со страницы Эрика Айдла (https://en.wikipedia.org/wiki/Eric_Idle), вы сможете найти наименьшее количество переходов по ссылкам, которые приведут вас на страницу Кевина Бейкона (https://en.wikipedia.org/wiki/Kevin_Bacon).

А как насчет нагрузки на сервер «Википедии»?

По данным Фонда Викимедиа (вышестоящей организации, отвечающей в том числе за «Википедию»), за одну секунду происходит примерно 2500 обращений к веб-ресурсам сайта, причем более 99 % из них относятся к «Википедии» (см. раздел Traffic volume на странице Wikimedia in figures, https://meta.wikimedia.org/wiki/Wikimedia_in_figures_-_Wikipedia#Traffic_volume). Из-за большого объема трафика ваши веб-скраперы вряд ли сколько-нибудь заметно повлияют на нагрузку сервера «Википедии». Тем не менее, если вы намерены активно использовать примеры кода, приведенные в этой книге, или будете разрабатывать собственные проекты для веб-скрапинга «Википедии», я призываю вас совершить необлагаемое налогом пожертвование в Фонд Викимедиа (https://wikimediafoundation.org/wiki/Ways_to_Give) — не только в качестве компенсации нагрузки на сервер, но и чтобы помочь сделать образовательные ресурсы более доступными для других пользователей.

Кроме того, имейте в виду: если вы планируете создать большой проект с применением данных из «Википедии», то стоит убедиться, что эти данные еще неоступны через API «Википедии» «(https://www.mediawiki.org/wiki/API:Main_page). «Википедия» часто используется в качестве сайта для демонстрации веб-скраперов и веб-краулеров, поскольку данный сайт имеет простую структуру HTML-кода и относительно стабилен. Однако эти же данные могут оказаться более доступными через API.

Вы уже, вероятно, знаете, как написать скрипт на Python, который бы получал произвольную страницу «Википедии» и создавал список ссылок, присутствующих на этой странице:

from urllib.request import urlopen

from bs4 import BeautifulSoup

 

html = urlopen('http://en.wikipedia.org/wiki/Kevin_Bacon')

bs = BeautifulSoup(html, 'html.parser')

for link in bs.find_all('a'):

    if 'href' in link.attrs:

        print(link.attrs['href'])

Если вы посмотрите на созданный список ссылок, то заметите, что в него вошли все ожидаемые статьи: Apollo 13, Philadelphia, Primetime Emmy Award и т.д. Однако есть и кое-что лишнее:

//wikimediafoundation.org/wiki/Privacy_policy

//en.wikipedia.org/wiki/Wikipedia:Contact_us

Дело в том, что на каждой странице «Википедии» есть боковая панель, нижний и верхний колонтитулы с множеством ссылок; также есть ссылки на страницы категорий, обсуждений и другие страницы, которые не содержат полезных статей:

/wiki/Category:Articles_with_unsourced_statements_from_April_2014

/wiki/Talk:Kevin_Bacon

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

они находятся внутри тега div, у которого атрибут id имеет значение bodyContent;

• в их URL нет двоеточий;

• их URL начинаются с /wiki/.

Мы можем немного изменить код, включив в него эти правила, и получить только нужные ссылки на статьи, воспользовавшись регулярным выражением ^(/wiki/)((?!:).)*$"):

from urllib.request import urlopen

from bs4 import BeautifulSoup

import re

 

html = urlopen('http://en.wikipedia.org/wiki/Kevin_Bacon')

bs = BeautifulSoup(html, 'html.parser')

for link in bs.find('div', {'id':'bodyContent'}).find_all(

    'a', href=re.compile('^(/wiki/)((?!:).)*$')):

    if 'href' in link.attrs:

        print(link.attrs['href'])

Запустив этот код, мы получим список всех URL статей, на которые ссылается статья «Википедии» о Кевине Бейконе.

Скрипт, который находит все ссылки на статьи для одной жестко заданной статьи «Википедии», конечно, интересен, однако с практической точки зрения довольно бесполезен. Вы должны уметь, взяв этот код за основу, преобразовать его примерно в такую программу.

Отдельная функция getLinks принимает URL статьи «Википедии» в формате /wiki/<Название_статьи> и возвращает список всех URL, на которые ссылается эта статья, в том же формате.

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

Вот полный код программы, которая это делает:

from urllib.request import urlopen

from bs4 import BeautifulSoup

import datetime

import random

import re

 

random.seed(datetime.datetime.now())

def getLinks(articleUrl):

    html = urlopen('http://en.wikipedia.org{}'.format(articleUrl))

    bs = BeautifulSoup(html, 'html.parser')

    return bs.find('div', {'id':'bodyContent'}).find_all('a',

         href=re.compile('^(/wiki/)((?!:).)*$'))

 

links = getLinks('/wiki/Kevin_Bacon')

while len(links) > 0:

    newArticle = links[random.randint(0, len(links)-1)].attrs['href']

    print(newArticle)

    links = getLinks(newArticle)

Сразу после импорта необходимых библиотек программа присваивает генератору случайных чисел начальное значение, равное текущему системному времени, чем обеспечивает новый и интересный случайный путь по статьям «Википедии» практически при каждом запуске программы.

Псевдослучайные числа и случайные начальные значения

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

Компьютеры хорошо справляются с вычислением правильных решений, но откровенно слабы, когда нужно придумать что-то новое. По этой причине случайные числа могут стать проблемой. Большинство алгоритмов генерации этих чисел стремятся создать равномерно распределенную и труднопредсказуемую числовую последовательность, однако для запуска работы такого алгоритма необходимо «начальное» число. Если оно каждый раз будет одним и тем же, то алгоритм станет всякий раз генерировать одну и ту же последовательность «случайных» чисел. Именно поэтому я использовала значение системных часов в качестве начального значения для создания новых последовательностей случайных чисел и, следовательно, генерации последовательностей случайных статей. Благодаря этому выполнять программу будет немного интереснее.

Любопытно, что генератор псевдослучайных чисел Python работает по алгоритму Мерсенна Твистера (Mersenne Twister). Он генерирует труднопредсказуемые и равномерно распределенные случайные числа, однако несколько загружает процессор. Случайные числа — это хорошо, но за все приходится платить!

Затем программа определяет функцию getLinks, которая принимает URL статьи в формате /wiki/..., добавляет в начало имя домена «Википедии» http://en.wikipedia.org и получает объект BeautifulSoup с HTML-кодом, который находится по этому адресу. Затем функция извлекает список тегов со ссылками на статьи, исходя из описанных ранее параметров, и возвращает их.

В основной части программы сначала создается список тегов со ссылками на статьи (переменная links), в котором содержатся ссылки, найденные на начальной странице https://en.wikipedia.org/wiki/Kevin_Bacon. Затем программа выполняет цикл и находит тег со случайной ссылкой на статью, извлекает оттуда атрибут href, выводит страницу и получает по извлеченному URL новый список ссылок.

Разумеется, решение задачи «Шесть шагов по “Википедии”» не ограничивается созданием веб-скрапера, переходящего с одной страницы на другую. Нам также необходимо хранить и анализировать полученные данные. Продолжение решения этой задачи вы найдете в главе 6.

Обрабатывайте исключения!

По большей части обработка исключений в этих примерах пропущена для краткости, однако следует помнить: здесь может возникнуть множество потенциальных ловушек. Что, если, например, «Википедия» изменит имя тега bodyContent? Тогда при попытке извлечь текст из тега программа выдаст исключение AttributeError.

Таким образом, несмотря на то, что эти сценарии достаточно хороши в качестве тщательно контролируемых примеров, в автономном коде готового приложения требуется обрабатывать исключения гораздо лучше, чем позволяет описать объем данной книги. Для получения дополнительной информации об этом вернитесь к главе 1.

Сбор информации со всего сайта

В предыдущем разделе мы прошли по сайту, переходя от одной случайно выбранной ссылки к другой. Но как быть, если нужно систематизировать сайт и составить каталог или просмотреть все страницы? Сбор информации со всего сайта, особенно большого, — процесс, требующий интенсивного использования памяти. Лучше всего с этим справляются приложения, имеющие быстрый доступ к базе данных для хранения результатов краулинга. Однако мы можем исследовать поведение таких приложений, не выполняя их в полном объеме. Подробнее об их выполнении с применением базы данных см. в главе 6.

Темный и глубокий Интернет

Вероятно, вам часто приходилось слышать о глубоком, темном или скрытом Интер­нете, особенно в последних новостях. Что имеется в виду?

Глубокий Интернет — это любая часть Сети, выходящая за пределы видимого Интерне­та. Видимой называют ту часть Интернета, которая индексируется поисковыми системами. Несмотря на большой разброс оценок, глубокий Интернет почти наверняка составляет около 90 % всего Интернета. Поскольку Google не способен отправлять формы или находить страницы, на которые не ссылается домен верхнего уровня, а также не выполняет поиск сайтов, для которых это запрещено в файле robots.txt, видимый Интернет продолжает составлять относительно небольшую часть Сети.

Темный Интернет, также известный как Даркнет, — нечто совершенно иное. Он работает на той же сетевой аппаратной инфраструктуре, но использует браузер Tor или другой клиент, у которого протокол приложения работает поверх HTTP, обеспечивая безопасный канал для обмена информацией. В темном Интернете, как и на обычном сайте, тоже можно выполнять веб-скрапинг, однако эта тема выходит за пределы данной книги.

В отличие от темного, в глубоком Интернете веб-скрапинг выполняется относительно легко. Многие инструменты, описанные в этой книге, научат вас, как выполнять веб-скрапинг и сбор информации во многих местах, недоступных для ботов Google.

В каких случаях сбор данных со всего сайта полезен, а в каких — вреден? Веб-скраперы, которые перебирают весь сайт, хороши во многих случаях, включая следующие.

Формирование карты сайта. Несколько лет назад мне встретилась задача: важный клиент хотел оценить затраты на редизайн сайта, однако не хотел предоставлять моей компании доступ ко внутренним компонентам существующей системы управления контентом и у него не было общедоступной карты сайта. Я воспользовалась веб-краулером, чтобы пройти по всему сайту, собрать все внутренние ссылки и разместить страницы в структуре папок, соответствующей той, что применялась на сайте. Это позволило мне быстро обнаружить разделы сайта, о которых я даже не подозревала, и точно подсчитать, сколько эскизов страниц потребуется создать и какой объем контента необходимо перенести.

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

Общий подход к полному сбору данных с сайта заключается в том, чтобы начать со страницы верхнего уровня (например, с начальной страницы) и построить список всех ее внутренних ссылок. Затем обойти все страницы, на которые указывают эти ссылки, и на каждой из них собрать дополнительные списки ссылок, запускающие очередной этап сбора данных.

Конечно же, в такой ситуации количество ссылок стремительно растет. Если на каждой странице есть десять внутренних ссылок, а глубина сайта составляет пять страниц (что довольно типично для сайта среднего размера), то необходимо проверить 105, то есть 100 000 страниц, чтобы с уверенностью утверждать: сайт пройден полностью. Как ни странно, хоть и «пять страниц в глубину и десять внутренних ссылок на каждой странице» — довольно типичный размер сайта, очень немногие сайты действительно насчитывают 100 000 и более страниц. Причина, конечно, состоит в том, что подавляющее большинство внутренних ссылок являются дубликатами.

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

from urllib.request import urlopen

from bs4 import BeautifulSoup

import re

 

pages = set()

def getLinks(pageUrl):

    global pages

    html = urlopen('http://en.wikipedia.org{}'.format(pageUrl))

    bs = BeautifulSoup(html, 'html.parser')

    for link in bs.find_all('a', href=re.compile('^(/wiki/)')):

        if 'href' in link.attrs:

            if link.attrs['href'] not in pages:

                # мы нашли новую страницу

                newPage = link.attrs['href']

                print(newPage)

                pages.add(newPage)

                getLinks(newPage)

getLinks('')

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

Изначально функция getLinks вызывается с пустым URL, то есть для начальной страницы «Википедии»: к пустому URL внутри функции добавляется http://en.wikipedia.org. Затем функция просматривает каждую ссылку на первой странице и проверяет, есть ли она в глобальном множестве страниц (множестве страниц, с которыми скрипт уже встречался). Если нет, то ссылка добавляется в список, выводится на экран и функция getLinks вызывается для нее рекурсивно.

Осторожно с рекурсией

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

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

Для «плоских» сайтов глубиной менее 1000 ссылок данный метод обычно — за редким исключением — работает хорошо. Например, однажды мне встретилась ошибка в динамически генерируемом URL, поскольку ссылка на следующую страницу зависела от адреса текущей страницы. Это привело к бесконечно повторяющимся путям, таким как /blogs/blogs.../blogs/blog-post.php.

Однако, как правило, этот рекурсивный метод должен подходить для любого обычного сайта, с которым вы, скорее всего, столкнетесь.

Сбор данных со всего сайта. Веб-краулеры были бы довольно скучными программами, если бы только переходили с одной страницы на другую. Полезными они могут быть, что-то делая на странице, на которую перешли. Рассмотрим пример веб-скрапера, который бы выбирал заголовок, первый абзац контента страницы и ссылку на режим редактирования страницы (если таковая существует).

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

У любой страницы (независимо от статуса: статья, история редактирования или любая другая страница) есть заголовок, заключенный в теги h1

span, и это единственный тег h1 на странице.

• Как уже говорилось, весь основной текст находится внутри тега div#bodyCon­tent. Но если вы хотите выделить конкретную часть текста — например, получить доступ только к первому абзацу, — то лучше использовать div#mw-content-text

p (выбрать только тег первого абзаца). Это подходит для всех контентных страниц, кроме страниц файлов (например, https://en.wikipedia.org/wiki/File:Orbit_of_274301_Wikipedia.svg), на которых нет разделов основного текста.

• Ссылки для редактирования существуют только на страницах статей. Если такая ссылка есть, то она находится в теге li#ca-edit, в li#ca-edit

span
a.

Изменив исходный код нашего веб-краулера, мы можем создать комбинированную программу для краулинга и сбора данных (или по крайней мере для вывода данных на экран):

from urllib.request import urlopen

from bs4 import BeautifulSoup

import re

 

pages = set()

def getLinks(pageUrl):

    global pages

    html = urlopen('http://en.wikipedia.org{}'.format(pageUrl))

    bs = BeautifulSoup(html, 'html.parser')

    try:

        print(bs.h1.get_text())

        print(bs.find(id ='mw-content-text').find_all('p')[0])

        print(bs.find(id='ca-edit').find('span')

             .find('a').attrs['href'])

    except AttributeError:

        print('This page is missing something! Continuing.')

 

    for link in bs.find_all('a', href=re.compile('^(/wiki/)')):

        if 'href' in link.attrs:

            if link.attrs['href'] not in pages:

                # мы нашли новую страницу

                newPage = link.attrs['href']

                print('-'*20)

                print(newPage)

                pages.add(newPage)

                getLinks(newPage)

getLinks('')

Цикл for в этой программе, по сути, такой же, как и в первоначальной программе сбора данных (для ясности добавлен вывод дефисов в качестве разделителей контента).

Поскольку никогда нельзя быть полностью уверенными в том, что на каждой странице будут присутствовать все нужные данные, операторы print расположены в соответствии с вероятностью наличия этих данных на странице. Так, тег заголовка h1 есть на каждой странице (во всяком случае, насколько я могу судить), вследствие чего мы сначала пытаемся получить эти данные. Текстовый контент присутствует на большинстве страниц (за исключением страниц файлов), так что этот фрагмент данных извлекается вторым. Кнопка редактирования есть только на страницах с заголовками и текстовым контентом, и то не на всех.

Разные схемы для различных потребностей

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

Вы могли заметить: в этом и во всех предыдущих примерах мы не столько «собирали» данные, сколько «выводили» их. Очевидно, что данными, выводимыми в окно терминала, сложно манипулировать. Подробнее о хранении информации и создании баз данных вы узнаете в главе 5.

Обработка перенаправлений

Перенаправления позволяют веб-серверу использовать имя текущего домена или URL для контента, находящегося в другом месте. Существует два типа перенаправлений:

перенаправления на стороне сервера, когда URL изменяется до загрузки страницы;

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

С перенаправлениями на стороне сервера вам обычно ничего не нужно делать. Если вы используете библиотеку urllib для Python 3.x, то она обрабатывает перенаправления автоматически! В случае применения библиотеки запросов проследите, чтобы флаг allow_redirects имел значение True:

r = requests.get( 'http://github.com' , allow_redirects=True)

Просто помните, что иногда URL сканируемой страницы может не совпадать с URL, по которому расположена эта страница.

Подробнее о перенаправлениях на стороне клиента, которые выполняются с помощью JavaScript или HTML, см. в главе 12.

Сбор информации с нескольких сайтов

Каждый раз, когда я делаю доклад о взломе веб-страниц, кто-нибудь обязательно спрашивает: «Как построить Google?» Мой ответ всегда состоит из двух частей: «Во-первых, вам нужно где-то взять много миллиардов долларов, чтобы купить крупнейшие в мире хранилища данных и разместить их в скрытых местах по всему миру. Во-вторых, вам нужно разработать веб-краулер».

В 1996 году, когда Google только начиналась, она состояла всего из двух аспирантов из Стэнфорда, у которых был старый сервер и веб-краулер, написанный на Python. Теперь, зная, как работает веб-скрапинг, вы официально располагаете инструментами, необходимыми для того, чтобы стать следующим технологическим мультимиллиардером!

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

Как и в предыдущем примере, веб-краулеры, которые мы намерены создать, будут переходить по ссылкам от страницы к странице, формируя карту сети. Однако на этот раз они не станут игнорировать внешние ссылки, а, наоборот, будут переходить по ним.

Впереди — неведомые воды

Имейте в виду: код, представленный ниже, может переходить в любую точку Интернета. Если мы что-то и узнали из «Шести шагов по “Википедии”», так это то, что всего за несколько переходов можно полностью уйти с сайта, например, http://www.sesamestreet.org, и попасть в гораздо менее приятное место.

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

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

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

• Попав на определенный сайт, должен ли мой краулер сразу переходить по следующей внешней ссылке на другой сайт, или же ему следует задержаться на какое-то время на этом и изучить его более углубленно?

• Существуют ли какие-либо условия, при которых я бы не хотел выполнять веб-скрапинг текущего сайта? Интересует ли меня контент на иностранных языках?

• Как я буду защищаться от судебных исков, если мой веб-краулер привлечет внимание веб-мастера одного из сайтов, на которые попадет? (Подробнее об этом см. в главе 18.)

Менее чем в 60 строках кода можно без труда уместить гибкий набор функций Python, комбинация которых дает различные типы веб-скраперов:

from urllib.request import urlopen

from urllib.parse import urlparse

from bs4 import BeautifulSoup

import re

import datetime

import random

 

pages = set()

random.seed(datetime.datetime.now())

 

# Получить список всех внутренних ссылок, найденных на странице.

def getInternalLinks(bs, includeUrl):

    includeUrl = '{}://{}'.format(urlparse(includeUrl).scheme,

        urlparse(includeUrl).netloc)

    internalLinks = []

    # найти все ссылки, которые начинаются с "/"

    for link in bs.find_all('a',

        href=re.compile('^(/|.*'+includeUrl+')')):

        if link.attrs['href'] is not None:

            if link.attrs['href'] not in internalLinks:

                if(link.attrs['href'].startswith('/')):

                    internalLinks.append(

                        includeUrl+link.attrs['href'])

                else:

                    internalLinks.append(link.attrs['href'])

    return internalLinks

 

# Получить список всех внешних ссылок, найденных на странице.

def getExternalLinks(bs, excludeUrl):

    externalLinks = []

    # Найти все ссылки, которые начинаются с "http" или "www",

    # не содержащие текущий URL.

    for link in bs.find_all('a',

        href=re.compile('^(http|www)((?!'+excludeUrl+').)*$')):

        if link.attrs['href'] is not None:

            if link.attrs['href'] not in externalLinks:

                externalLinks.append(link.attrs['href'])

    return externalLinks

 

    def getRandomExternalLink(startingPage):

        html = urlopen(startingPage)

        bs = BeautifulSoup(html, 'html.parser')

        externalLinks = getExternalLinks(bs,

            urlparse(startingPage).netloc)

        if len(externalLinks) == 0:

            print('No external links, looking around the site for one')

            domain = '{}://{}'.format(urlparse(startingPage).scheme,

                urlparse(startingPage).netloc)

            internalLinks = getInternalLinks(bs, domain)

            return getRandomExternalLink(internalLinks[random.randint(0,

                                        len(internalLinks)-1)])

        else:

            return externalLinks[random.randint(0, len(externalLinks)-1)]

 

def followExternalOnly(startingSite):

    externalLink = getRandomExternalLink(startingSite)

    print('Random external link is: {}'.format(externalLink))

    followExternalOnly(externalLink)

    followExternalOnly('http://oreilly.com')

Эта программа начинает с сайта http://oreilly.com и случайным образом переходит от одной внешней ссылки к другой. Вот пример результатов, которые она выводит:

http://igniteshow.com/

http://feeds.feedburner.com/oreilly/news

http://hire.jobvite.com/CompanyJobs/Careers.aspx?c=q319

http://makerfaire.com/

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

Принцип работы этой программы показан на рис. 3.1 в виде блок-схемы.

 

Рис. 3.1. Блок-схема нашего сценария краулинга сайтов

Не используйте учебные примеры программ в реальных приложениях

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

Одним из простых способов повысить надежность этого веб-краулера было бы объединить его с кодом обработки исключений при соединении, описанном в главе 1. Это позволило бы в случае возникновения ошибки HTTP или исключения на сервере при получении страницы выбрать другой URL и перейти по нему.

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

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

# Составляет список из всех внешних URL, найденных на сайте.

allExtLinks = set()

allIntLinks = set()

 

def getAllExternalLinks(siteUrl):

    html = urlopen(siteUrl)

    domain = '{}://{}'.format(urlparse(siteUrl).scheme,

        urlparse(siteUrl).netloc)

    bs = BeautifulSoup(html, 'html.parser')

    internalLinks = getInternalLinks(bs, domain)

    externalLinks = getExternalLinks(bs, domain)

 

    for link in externalLinks:

        if link not in allExtLinks:

            allExtLinks.add(link)

            print(link)

    for link in internalLinks:

        if link not in allIntLinks:

            allIntLinks.add(link)

            getAllExternalLinks(link)

 

allIntLinks.add('http://oreilly.com')

getAllExternalLinks('http://oreilly.com')

Этот код можно представить как два совместно работающих цикла: один собирает внутренние ссылки, а второй — внешние. Блок-схема программы выглядит примерно так (рис. 3.2).

 

Рис. 3.2. Блок-схема веб-краулера сайтов, который собирает все внешние ссылки

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

4 Благодарю сайт The Oracle of Bacon (http://oracleofbacon.org/), с помощью которого я удовлетворила свое любопытство относительно этой цепочки.

Например, Эрик Айдл (Eric Idle) снялся в фильме «Дадли Справедливый» (Dudley Do-Right) с Бренданом Фрейзером (Brendan Fraser), который, в свою очередь, снялся с Кевином Бейконом (Kevin Bacon) в «Воздухе, которым я дышу» (The Air I Breathe)4. В этом случае цепь от Эрика Айдла до Кевина Бейкона состоит всего из трех элементов.

Благодарю сайт The Oracle of Bacon (http://oracleofbacon.org/), с помощью которого я удовлетворила свое любопытство относительно этой цепочки.

Глава 4. Модели веб-краулинга

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

Вам могут предложить собрать новостные статьи или публикации из блогов, размещенных на различных сайтах, у каждого из которых свои шаблоны и макеты. На одном сайте тег h1 может содержать заголовок статьи, а на другом — заголовок самого сайта, а заголовок статьи будет заключен в тег <spanid="title">.

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

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

Несмотря на то что вариантов применения веб-краулеров бесчисленное множество, большие масштабируемые веб-краулеры, как правило, относятся к одному из нескольких типов. Исследуя эти типы и распознавая ситуации, в которых они используются, можно значительно повысить удобство сопровождения и надежность своих веб-краулеров.

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

Планирование и определение объектов

Одна из распространенных ловушек веб-скрапинга — определение данных, которые разработчики намерены собирать, исключительно на основании того, что есть у них перед глазами. Например, желая собрать данные о товаре, можно было бы для начала заглянуть в магазин одежды и решить, что для каждого интересующего нас товара необходимо получить данные из следующих полей:

наименование товара;

• цена;

• описание;

• размеры;

• цвета;

• тип ткани;

• рейтинг клиентов.

Посмотрев на другой сайт, вы обнаружите, что у товара есть SKU (stock keeping unit, единицы хранения, или артикул, используемый для отслеживания и заказа товаров). Вы наверняка захотите собирать и эти данные, даже если их не было на первом сайте! И вы добавите в список данное поле:

• артикул.

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

твердая/мягкая обложка;

• матовая/глянцевая печать;

• количество отзывов клиентов;

• ссылка на сайт производителя.

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

Принимая решение о том, какие данные собирать, зачастую лучше игнорировать сайты. Нельзя запустить проект, рассчитанный на то, чтобы стать большим и масштабируемым, посмотрев только на один сайт и спросив себя: «Что здесь есть?» Вместо этого нужно задать другой вопрос: «Что мне нужно?» — а затем найти способы поиска необходимой информации.

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

• название товара;

• производитель;

• идентификационный номер товара (если он есть и интересен вам).

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

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

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

• Если эти данные когда-нибудь могут пригодиться (а могут и нет) — насколько сложно будет вернуться и собрать их?

• Являются ли эти данные избыточными относительно уже собранных?

• Имеет ли логический смысл хранить данные именно в этом объекте? (Как уже упоминалось, сохранение описания в качестве свойства товара не имеет смысла, если описание одного и того же товара может быть разным на разных сайтах.)

Определившись, какие данные нужно собрать, важно задать себе еще несколько вопросов, чтобы затем решить, как хранить и обрабатывать эти данные в коде.

• Эти данные разреженные или плотные? Свойственны ли они любому товару, в любом списке или же существуют только для небольшого множества товаров?

• Насколько велик объем данных?

• Особенно это касается больших данных: нужно ли будет регулярно получать их каждый раз при выполнении анализа или только в отдельных случаях?

• Насколько переменчивы данные этого типа? Придется ли регулярно добавлять новые атрибуты, изменять типы (например, часто могут добавляться ткани с новыми узорами), или же эти данные никогда не изменяются (допустим, размеры обуви)?

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

название товара;

• производитель;

• идентификационный номер товара (если есть и нужен);

атрибуты (необязательный параметр; список или словарь).

Тип атрибута выглядит так:

имя атрибута;

• значение атрибута.

Со временем это позволит гибко добавлять новые атрибуты товара, не прибегая к необходимости изменять схему данных или переписывать код. Определяясь с тем, как хранить эти атрибуты в базе данных, вы можете решить записывать их в поле attribute в формате JSON или же хранить каждый атрибут в отдельной таблице, откуда извлекать их по идентификатору товара. Подробнее о реализации этих типов моделей баз данных см. в главе 6.

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

идентификатор товара;

• идентификатор магазина;

• цена;

• дата или метка времени для данной цены.

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

идентификатор товара;

• тип экземпляра (в данном случае размер рубашки).

Тогда каждая цена будет выглядеть так:

идентификатор экземпляра товара;

• идентификатор магазина;

• цена;

• дата или метка времени для данной цены.

Тема «товары и цены» может показаться слишком узкой, однако основные вопросы, которые нужно себе задать, и логика, используемая при разработке этих объектов Python, применимы практически в любой ситуации.

При веб-скрапинге новостных публикаций может потребоваться следующая основная информация:

заголовок;

• автор;

• дата;

• контент.

Но предположим, что некоторые статьи содержат «дату изменения», или «связанные публикации», или «количество перепостов в социальных сетях». Вам нужна эта информация? Она имеет отношение к вашему проекту? Как эффективно и гибко хранить количество перепостов в социальных сетях, если только некоторые новостные сайты используют все формы социальных сетей, а со временем популярность разных социальных сетей может увеличиваться или уменьшаться?

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

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

Работа с различными макетами сайтов

Одно из наиболее впечатляющих достижений поисковых систем, таких как Google, состоит в возможности извлекать релевантные и полезные данные с различных сайтов, не имея предварительных знаний об их структуре. Мы, люди, способны с первого взгляда определить, где у страницы заголовок, а где — основ­ной контент (за исключением случаев крайне плохого веб-дизайна), однако заставить бот делать то же самое гораздо труднее.

К счастью, в большинстве случаев при веб-краулинге мы не намерены собирать данные с сайтов, которые никогда прежде не видели. Обычно речь идет максимум о нескольких десятках сайтов, предварительно отобранных человеком. Это значит, что нам не понадобятся сложные алгоритмы или машинное обучение для определения того, какой текст на странице «больше всего похож на заголовок», а какой, скорее всего, является «основным контентом». Все эти элементы можно задать вручную.

Наиболее очевидный подход — написать отдельный веб-краулер или парсер страниц для каждого сайта. Каждый такой краулер может принимать URL, строку или объект BeautifulSoup и возвращать результат веб-скрапинга в виде объекта Python.

Ниже приведены пример класса Content (представляющего собой фрагмент контента сайта, например новостную публикацию) и две функции веб-скрапинга, которые принимают объект BeautifulSoup и возвращают экземпляр Content:

import requests

 

class Content:

    def __init__(self, url, title, body):

        self.url = url

        self.title = title

        self.body = body

 

def getPage(url):

    req = requests.get(url)

    return BeautifulSoup(req.text, 'html.parser')

 

def scrapeNYTimes(url):

    bs = getPage(url)

    title = bs.find('h1').text

    lines = bs.select('div.StoryBodyCompanionColumn div p')

    body = '\n'.join([line.text for line in lines])

    return Content(url, title, body)

 

def scrapeBrookings(url):

    bs = getPage(url)

    title = bs.find('h1').text

    body = bs.find('div', {'class', 'post-body'}).text

    return Content(url, title, body)

 

url = 'https://www.brookings.edu/blog/future-development/2018/01/26/'

    'delivering-inclusive-urban-access-3-uncomfortable-truths/'

content = scrapeBrookings(url)

print('Title: {}'.format(content.title))

print('URL: {}\n'.format(content.url))

print(content.body)

 

url = 'https://www.nytimes.com/2018/01/25/opinion/sunday/'

    'silicon-valley-immortality.html'

content = scrapeNYTimes(url)

print('Title: {}'.format(content.title))

print('URL: {}\n'.format(content.url))

print(content.body)

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

находит элемент заголовка и извлекает оттуда текст заголовка;

• находит основной контент статьи;

• при необходимости находит другие элементы контента;

• возвращает объект Content, созданный с помощью ранее найденных строк.

Единственное, что здесь действительно зависит от сайта, — это CSS-селекторы, используемые для получения каждого элемента информации. Функции BeautifulSoup find и find_all принимают два аргумента: строку тега и словарь атрибутов в формате «ключ — значение», вследствие чего эти аргументы можно передавать как параметры, которые определяют структуру сайта и расположение нужных данных.

Чтобы было еще удобнее, вместо аргументов тегов и пар «ключ — значение» можно использовать функцию BeautifulSoup select, принимающую строку CSS-селектора для каждого элемента информации, который вы хотите получить, и разместить все эти селекторы в словарном объекте:

class Content:

    """

    Общий родительский класс для всех статей/страниц.

    """

    def __init__(self, url, title, body):

        self.url = url

        self.title = title

        self.body = body

 

    def print(self):

    """

    Гибкая функция печати, управляющая выводом данных.

    """

    print('URL: {}'.format(self.url))

    print('TITLE: {}'.format(self.title))

    print('BODY:\n{}'.format(self.body))

 

class Website:

    """

    Содержит информацию о структуре сайта.

    """

    def __init__(self, name, url, titleTag, bodyTag):

        self.name = name

        self.url = url

        self.titleTag = titleTag

        self.bodyTag = bodyTag

Обратите внимание: в классе Website хранится не информация, собранная с разных страниц, а инструкции о том, как ее собирать. Так, здесь хранится не заголовок «Название моей страницы», а лишь строка с тегом h1, который указывает на то, где содержатся заголовки. Именно поэтому класс называется Website (его информация относится ко всему сайту), а не Content (в котором содержится информация только с одной страницы).

С помощью классов Content и Website можно написать класс Crawler для веб-скрапинга заголовка и контента, размещенных по любому URL веб-страницы, принадлежащей данному сайту:

import requests

from bs4 import BeautifulSoup

 

class Crawler:

    def getPage(self, url):

        try:

            req = requests.get(url)

        except requests.exceptions.RequestException:

            return None

        return BeautifulSoup(req.text, 'html.parser')

 

    def safeGet(self, pageObj, selector):

        """

        Служебная функция, используемая для получения строки

        содержимого из объекта BeautifulSoup и селектора.

        Если объект для данного селектора не найден,

        то возвращает пустую строку.

        """

        selectedElems = pageObj.select(selector)

        if selectedElems is not None and len(selectedElems) > 0:

            return '\n'.join(

            [elem.get_text() for elem in selectedElems])

        return ''

 

    def parse(self, site, url):

        """

        Извлекает содержимое страницы с заданным URL.

        """

        bs = self.getPage(url)

        if bs is not None:

            title = self.safeGet(bs, site.titleTag)

            body = self.safeGet(bs, site.bodyTag)

            if title != '' and body != '':

                content = Content(url, title, body)

                content.print()

А следующий код определяет объекты сайтов и запускает весь процесс:

crawler = Crawler()

 

siteData = [

    ['O\'Reilly Media', 'http://oreilly.com',

    'h1', 'section#product-description'],

    ['Reuters', 'http://reuters.com', 'h1',

    'div.StandardArticleBody_body_1gnLA'],

    ['Brookings', 'http://www.brookings.edu',

    'h1', 'div.post-body'],

    ['New York Times', 'http://nytimes.com',

    'h1', 'div.StoryBodyCompanionColumn div p']

]

websites = []

for row in siteData:

    websites.append(Website(row[0], row[1], row[2], row[3]))

 

crawler.parse(websites[0], 'http://shop.oreilly.com/product/'\

    '0636920028154.do')

crawler.parse(websites[1], 'http://www.reuters.com/article/'\

    'us-usa-epa-pruitt-idUSKBN19W2D0')

crawler.parse(websites[2], 'https://www.brookings.edu/blog/'\

    'techtank/2016/03/01/idea-to-retire-old-methods-of-policy-education/')

crawler.parse(websites[3], 'https://www.nytimes.com/2018/01/'\

    '28/business/energy-environment/oil-boom.html')

На первый взгляд этот новый способ может показаться удивительно простым, по сравнению с написанием отдельной функции Python для каждого сайта. Однако представьте, что произойдет при переходе от системы с четырьмя сайтами-источниками к системе с 20 или 200 источниками.

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

Конечно, здесь есть недостаток: мы отказываемся от определенной гибкости. В первом примере у каждого сайта есть собственная функция, написанная в свободной форме, для выбора и — если получение результата того требует — синтаксического анализа HTML-кода. Во втором примере все сайты должны иметь схожую структуру, в которой гарантированно существуют определенные поля. Полученные из них данные должны быть чистыми, а каждому интересу­ющему нас полю следует иметь уникальный и надежный CSS-селектор.

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

Структурирование веб-краулеров

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

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

Веб-краулинг с помощью поиска

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

Большинство сайтов получают список результатов поиска по определенной теме, передавая ее в виде строки через параметр URL, например: http://example.com?search=мояТема. Первую часть этого URL можно сохранить как свойство объекта Website и потом просто каждый раз добавлять к нему тему.

• Выполнив поиск, большинство сайтов формируют страницы результатов в виде легко идентифицируемого списка ссылок, обычно заключенных в удобный тег наподобие <spanclass="result">, точный формат которого тоже можно сохранить в виде свойства объекта Website.

• Каждая ссылка на результат поиска представляет собой либо относительный URL (такой как /articles/page.html), либо абсолютный (например, http://example.com/articles/page.html). Независимо от того, какой вариант вы ожидаете — абсолютный или относительный, — URL можно сохранить как свойство объекта Website.

• Найдя и нормализовав URL на странице поиска, мы успешно сводим задачу к примеру, рассмотренному в предыдущем разделе, — извлечению данных со страницы сайта, имеющей заданный формат.

Рассмотрим реализацию этого алгоритма в виде кода. Класс Content здесь почти такой же, как в предыдущих примерах. Мы только добавили свойство URL, которое позволит отслеживать, откуда взят этот контент:

class Content:

    """Общий родительский класс для всех статей/страниц"""

 

    def __init__(self, topic, url, title, body):

        self.topic = topic

        self.title = title

        self.body = body

        self.url = url

 

    def print(self):

        """

        Гибкая функция печати, управляющая выводом данных

        """

        print('New article found for topic: {}'.format(self.topic))

        print('URL: {}'.format(self.url))

        print('TITLE: {}'.format(self.title))

        print('BODY:\n{}'.format(self.body))

Мы также добавили к классу Website несколько новых свойств. Свойство searchUrl определяет, по какому адресу следует обратиться, чтобы получить результаты поиска, если добавить к нему искомую тему. Свойство resultListing определяет блок, в котором заключена информация о каждом результате поиска, а resultUrl — тег внутри этого блока, содержащий точный URL результата. Свойство absoluteUrl представляет собой значение логического типа, указывающее на то, какими ссылками являются результаты поиска — абсолютными или относительными.

class Website:

    """Содержит информацию о структуре сайта"""

 

    def __init__(self, name, url, searchUrl, resultListing,

        resultUrl, absoluteUrl, titleTag, bodyTag):

        self.name = name

        self.url = url

        self.searchUrl = searchUrl

        self.resultListing = resultListing

        self.resultUrl = resultUrl

        self.absoluteUrl=absoluteUrl

        self.titleTag = titleTag

        self.bodyTag = bodyTag

Мы немного расширили файл crawler.py — теперь в нем содержатся данные Website, список тем для поиска и два цикла, в которых перебираются все темы и сайты. В этом файле также содержится функция search, переходящая на страницу поиска заданного сайта с заданной темой и извлекающая оттуда все найденные URL, перечисленные на странице результатов поиска.

import requests

from bs4 import BeautifulSoup

 

class Crawler:

 

    def getPage(self, url):

        try:

            req = requests.get(url)

        except requests.exceptions.RequestException:

            return None

        return BeautifulSoup(req.text, 'html.parser')

 

    def safeGet(self, pageObj, selector):

        childObj = pageObj.select(selector)

        if childObj is not None and len(childObj) > 0:

            return childObj[0].get_text()

        return ''

 

    def search(self, topic, site):

        """

        Поиск на заданном сайте по заданной теме

        и сохранение всех найденных страниц.

        """

        bs = self.getPage(site.searchUrl + topic)

        searchResults = bs.select(site.resultListing)

        for result in searchResults:

            url = result.select(site.resultUrl)[0].attrs['href']

            # Проверить, является ли URL относительным или абсолютным.

            if(site.absoluteUrl):

                bs = self.getPage(url)

            else:

                bs = self.getPage(site.url + url)

            if bs is None:

                print('Something was wrong with that page or URL. Skipping!')

                return

            title = self.safeGet(bs, site.titleTag)

            body = self.safeGet(bs, site.bodyTag)

            if title != '' and body != '':

                content = Content(topic, title, body, url)

                content.print()

 

crawler = Crawler()

 

siteData = [

    ['O\'Reilly Media', 'http://oreilly.com', 'https://ssearch.oreilly.com/?q=',

    'article.product-result', 'p.title a', True, 'h1',

    'section#product-description'],

    ['Reuters', 'http://reuters.com', 'http://www.reuters.com/search/news?blob=',

    'div.search-result-content', 'h3.search-result-title a', False, 'h1',

    'div.StandardArticleBody_body_1gnLA'],

    ['Brookings', 'http://www.brookings.edu',

    'https://www.brookings.edu/search/?s=', 'div.list-content article',

    'h4.title a', True, 'h1', 'div.post-body']

]

sites = []

for row in siteData:

    sites.append(Website(row[0], row[1], row[2],

                         row[3], row[4], row[5], row[6], row[7]))

 

topics = ['python', 'data science']

for topic in topics:

    print('GETTING INFO ABOUT: ' + topic)

    for targetSite in sites:

        crawler.search(topic, targetSite)

Этот скрипт перебирает все темы из списка topics и, прежде чем приступить к веб-скрапингу по очередной теме, выводит предупреждение:

GETTING INFO ABOUT python

Затем скрипт просматривает все сайты из списка sites и сканирует каждый из них по каждой теме. Всякий раз, успешно находя информацию о странице, скрипт выводит ее в консоль:

New article found for topic: python

URL: http://example.com/examplepage.html

TITLE: Page Title Here

BODY: Body content is here

Обратите внимание: скрипт перебирает все темы, проходя по всем сайтам во внутреннем цикле. Почему бы не поступить наоборот, сначала перебрав все темы на одном сайте, а затем — на следующем? Цикл с перебором по темам позволяет более равномерно распределить нагрузку на веб-серверы. Это особенно важно, если наш список состоит из нескольких сотен тем и нескольких десятков сайтов. Не стоит направлять на один сайт сразу несколько десятков тысяч запросов; лучше сделать десять запросов, подождать несколько минут, затем сделать еще десять запросов, подождать еще несколько минут и т.д.

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

Сбор данных с сайтов по ссылкам

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

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

В отличие от примера сбора данных со страниц с результатами поиска, рассмотренного выше, данные типы краулеров не требуют структурированного метода поиска ссылок, поэтому атрибуты, описывающие страницу поиска, в объекте Website не нужны. Однако, поскольку краулеру не даны конкретные инструкции о том, где и как расположены ссылки, которые он ищет, требуются некие правила, указывающие на то, какие страницы следует выбрать. Для этого мы предоставляем targetPattern — регулярное выражение, описывающее нужные URL — и создаем логическую переменную absoluteUrl:

class Website:

    def __init__(self, name, url, targetPattern, absoluteUrl, titleTag, bodyTag):

        self.name = name

        self.url = url

        self.targetPattern = targetPattern

        self.absoluteUrl = absoluteUrl

        self.titleTag = titleTag

        self.bodyTag = bodyTag

 

class Content:

 

    def __init__(self, url, title, body):

        self.url = url

        self.title = title

        self.body = body

 

    def print(self):

        print('URL: {}'.format(self.url))

        print('TITLE: {}'.format(self.title))

        print('BODY:\n{}'.format(self.body))

Класс Content — тот же класс, что и в первом примере веб-краулера.

Класс Crawler стартует с начальной страницы сайта, находит внутренние ссылки и анализирует контент каждой из этих внутренних ссылок:

import re

 

class Crawler:

    def __init__(self, site):

        self.site = site

        self.visited = []

 

    def getPage(self, url):

        try:

            req = requests.get(url)

        except requests.exceptions.RequestException:

            return None

        return BeautifulSoup(req.text, 'html.parser')

 

    def safeGet(self, pageObj, selector):

        selectedElems = pageObj.select(selector)

        if selectedElems is not None and len(selectedElems) > 0:

            return '\n'.join([elem.get_text() for

                elem in selectedElems])

        return ''

 

    def parse(self, url):

        bs = self.getPage(url)

        if bs is not None:

            title = self.safeGet(bs, self.site.titleTag)

            body = self.safeGet(bs, self.site.bodyTag)

            if title != '' and body != '':

                content = Content(url, title, body)

                content.print()

 

    def crawl(self):

        """

        Получить ссылки с начальной страницы сайта

        """

        bs = self.getPage(self.site.url)

        targetPages = bs.find_all('a',

            href=re.compile(self.site.targetPattern))

        for targetPage in targetPages:

            targetPage = targetPage.attrs['href']

            if targetPage not in self.visited:

                self.visited.append(targetPage)

                if not self.site.absoluteUrl:

                    targetPage = '{}{}'.format(self.site.url, targetPage)

                self.parse(targetPage)

 

reuters = Website('Reuters', 'https://www.reuters.com', '^(/article/)', False,

    'h1', 'div.StandardArticleBody_body_1gnLA')

crawler = Crawler(reuters)

crawler.crawl()

Еще одно изменение, по сравнению с предыдущими примерами: объект Website (в данном случае переменная reuters), в свою очередь, является свойством объекта Crawler. Это позволяет удобно хранить в краулере посещенные страницы (visited), но также означает необходимость создания для каждого сайта нового краулера, вместо того чтобы многократно использовать один и тот же для проверки списка сайтов.

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

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

Сбор данных со страниц нескольких типов

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

По URL — все публикации в блогах могут содержать URL (например, http://example.com/blog/title-ofpost).

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

• По наличию на странице определенных тегов, идентифицирующих страницу, — теги можно использовать, даже если мы не собираем данные внутри них. Веб-краулер может искать элемент наподобие <divid="relatedproducts">, чтобы идентифицировать страницу как страницу товара, даже если вас не интересуют сопутствующие товары.

Чтобы отслеживать несколько типов страниц, вам понадобится создать на Python несколько типов объектов страниц. Это можно сделать следующими двумя способами.

Если страницы похожи (имеют в целом одинаковые типы контента), то можно добавить к существующему объекту веб-страницы атрибут pageType:

class Website:

    def __init__(self, name, url, titleTag, bodyTag, pageType):

        self.name = name

        self.url = url

        self.titleTag = titleTag

        self.bodyTag = bodyTag

        self.pageType = pageType

Если страницы хранятся в SQL-подобной базе данных, то такой тип структуры страниц указывает на вероятное хранение этих страниц в одной таблице, в которую будет добавлено поле pageType.

Если же страницы или контент, который вы ищете, заметно различаются (содержат поля разных типов), то может понадобиться создать отдельные объекты для каждого типа страниц. Конечно, все веб-страницы будут иметь нечто общее: URL и, вероятно, имя или заголовок. Это идеальная ситуация для использования подклассов:

class Webpage:

    def __init__(self, name, url, titleTag):

        self.name = name

        self.url = url

        self.titleTag = titleTag

Веб-краулер не будет использовать этот объект напрямую, однако на него будут ссылаться типы страниц:

class Product(Website):

    """Содержит информацию для веб-скрапинга страницы товара"""

    def __init__(self, name, url, titleTag, productNumberTag, priceTag):

        Website.__init__(self, name, url, TitleTag)

        self.productNumberTag = productNumberTag

        self.priceTag = priceTag

 

class Article(Website):

    """Содержит информацию для веб-скрапинга страницы статьи"""

    def __init__(self, name, url, titleTag, bodyTag, dateTag):

        Website.__init__(self, name, url, titleTag)

        self.bodyTag = bodyTag

        self.dateTag = dateTag

Страница Product расширяет базовый класс Website, добавляя к нему атрибуты productNumber и price, относящиеся лишь к товарам, а класс Article добавляет атрибуты body и date, которые к товарам неприменимы.

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

Размышления о моделях веб-краулеров

Собирать информацию из Интернета — словно пить из пожарного шланга. Ее слишком много, и не всегда понятно, что именно вам нужно и как это получить. Ответ на эти вопросы должен стать первым шагом в любом крупном (а иногда и небольшом) веб-проекте.

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

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

Кроме того, не следует игнорировать взаимосвязи между фрагментами данных. Предположим, вы ищете информацию, у которой в разных источниках есть свойства «тип», «размер» или «тема». Как вы будете хранить, извлекать и осмысливать эти атрибуты?

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

Глава 5. Scrapy

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

В этой главе вы познакомитесь с одной из лучших платформ для разработки веб-краулеров — Scrapy. Когда я работала над первым изданием данной книги, Scrapy для Python 3.x еще не была выпущена, поэтому ее упоминание в книге ограничилось одним разделом. С тех пор библиотека стала поддерживать Python 3.3+, в ней появились дополнительные функции, и я с удовольствием уделю ей не раздел, а целую главу.

Одна из проблем создания веб-краулеров состоит в том, что часто приходится выполнять одни и те же задачи: находить все ссылки на странице, оценивать разницу между внутренними и внешними ссылками, переходить на новые страницы. Эти основные стандартные операции полезно знать и уметь писать с нуля, но библиотека Scrapy способна многое из упомянутого сделать автоматически.

Конечно, Scrapy не читает мысли. Вам по-прежнему необходимо описать шаблоны страниц, указать точку, с которой следует начать работу, и построить правила для URL искомых страниц. Но и в этих случаях библиотека предоставляет чистую основу для построения четко структурированного кода.

Установка Scrapy

Scrapy предоставляет инструмент для скачивания библиотеки с ее сайта (http://scrapy.org/download/), а также инструкции по установке с помощью сторонних менеджеров, таких как pip.

Из-за сравнительно большого размера и сложности Scrapy обычно нельзя установить, как другие фреймворки, с помощью такой команды:

$ pip install Scrapy

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

Если вы решили установить Scrapy с помощью pip, то настоятельно рекомендую использовать виртуальное окружение (подробнее о виртуальных окружениях см. во врезке «Хранение библиотек непосредственно в виртуальных окружениях» на с. 28).

Я предпочитаю другой способ установки — с помощью менеджера пакетов Anaconda (https://docs.continuum.io/anaconda/). Это программный продукт, производимый компанией Continuum и предназначенный для того, чтобы сглаживать острые углы при поиске и установке популярных пакетов Python для обработки данных. В следующих главах мы будем использовать многие другие пакеты, которыми управляет Anaconda, такие как NumPy и NLTK.

После установки Anaconda можно установить Scrapy с помощью следующей команды:

conda install -c conda-forge scrapy

Если у вас возникнут проблемы или потребуется свежая информация, то обратитесь к руководству по установке Scrapy (https://doc.scrapy.org/en/latest/intro/install.html).

Инициализация нового «паука». После установки платформы Scrapy необходимо выполнить небольшую настройку для каждого «паука» (spider) — проекта Scrapy, который, как и обычный паук, занимается обходом сети. В этой главе я буду называть «пауком» именно проект Scrapy, а краулером — любую программу, которая занимается сбором данных во Всемирной паутине, независимо от того, использует она Scrapy или нет.

Чтобы создать нового «паука» в текущем каталоге, нужно ввести в командной строке следующую команду:

$ scrapy startproject wikiSpider

В результате в каталоге будет создан новый подкаталог, а в нем — проект под названием wikiSpider. Внутри этого каталога находится следующая файловая структура:

scrapy.cfg

wikiSpider

— spiders

    — __init.py__

— items.py

— middlewares.py

— pipelines.py

— settings.py

— __init.py__

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

Пишем простой веб-скрапер

Чтобы создать веб-краулер, нужно добавить в дочерний каталог wikiSpider новый файл wikiSpider/wikiSpider/article.py. Затем в этом файле article.py нужно написать следующее:

import scrapy

 

class ArticleSpider(scrapy.Spider):

    name='article'

 

    def start_requests(self):

        urls = [

            'http://en.wikipedia.org/wiki/Python_'

            '%28programming_language%29',

            'https://en.wikipedia.org/wiki/Functional_programming',

            'https://en.wikipedia.org/wiki/Monty_Python']

        return [scrapy.Request(url=url, callback=self.parse)

            for url in urls]

 

    def parse(self, response):

        url = response.url

        title = response.css('h1::text').extract_first()

        print('URL is: {}'.format(url))

        print('Title is: {}'.format(title))

Имя этого класса (ArticleSpider) отличается от имени каталога (wikiSpider), что указывает на следующее: данный класс отвечает за просмотр только страниц статей в рамках более широкой категории wikiSpider, которую мы впоследствии сможем использовать для поиска других типов страниц.

Для больших сайтов с разными типами контента можно создать отдельные элементы Scrapy для каждого типа (публикации в блогах, пресс-релизы, статьи и т.п.). У каждого из этих типов будут свои поля, но все они станут работать в одном проекте Scrapy. Имя каждого «паука» должно быть уникальным в рамках проекта.

Следует обратить внимание еще на две важные вещи: функции start_requests и parse.

Функция start_requests — это предопределенная Scrapy точка входа в программу, используемая для генерации объектов Request, которые в Scrapy применяются для сбора данных с сайта.

Функция parse — это функция обратного вызова, определяемая пользователем, которая передается в объект Request с помощью callback=self.parse. Позже мы рассмотрим более мощные трюки, которые возможны благодаря функции parse, но пока что она просто выводит заголовок страницы.

Для запуска «паука» article нужно перейти в каталог wikiSpider/wikiSpider и выполнить следующую команду:

$ scrapy runspider article.py

По умолчанию Scrapy выводит довольно подробные данные. Помимо отладочной информации, это будут примерно такие строки:

2018-01-21 23:28:57 [scrapy.core.engine] DEBUG: Crawled (200)

<GET https://en.wikipedia.org/robots.txt> (referer: None)

2018-01-21 23:28:57 [scrapy.downloadermiddlewares.redirect]

DEBUG: Redirecting (301) to <GET https://en.wikipedia.org/wiki/

Python_%28programming_language%29> from <GET http://en.wikipedia.org/

wiki/Python_%28programming_language%29>

2018-01-21 23:28:57 [scrapy.core.engine] DEBUG: Crawled (200)

<GET https://en.wikipedia.org/wiki/Functional_programming>

(referer: None)

URL is: https://en.wikipedia.org/wiki/Functional_programming

Title is: Functional programming

2018-01-21 23:28:57 [scrapy.core.engine] DEBUG: Crawled (200)

<GET https://en.wikipedia.org/wiki/Monty_Python> (referer: None)

URL is: https://en.wikipedia.org/wiki/Monty_Python

Title is: Monty Python

Веб-скрапер проходит по трем страницам, указанным в списке urls, собирает с них информацию и завершает работу.

«Паук» с правилами

«Паук», созданный в предыдущем разделе, не очень-то похож на веб-краулер, так как ограничен лишь списком предоставленных ему URL. Он не умеет самостоятельно искать новые страницы. Чтобы превратить этого «паука» в полноценный веб-краулер, нужно задействовать предоставляемый Scrapy класс CrawlSpider.

Где искать этот код в репозитории GitHub

К сожалению, фреймворк Scrapy нельзя просто запустить из редактора Jupyter, что затрудняет фиксацию внесения изменений в код. Для представления примеров кода в тексте главы мы сохранили веб-скрапер из предыдущего раздела в файле article.py, а следующий пример создания «паука» Scrapy, перебирающего несколько страниц, — в файле articles.py (обратите внимание на использование буквы s в конце слова articles).

Последующие примеры также будут храниться в отдельных файлах со своими именами, которые будут приводиться в соответствующих разделах. Убедитесь, что используете правильные имена файлов при запуске этих примеров.

Следующий класс находится в файле articles.py в репозитории GitHub:

from scrapy.contrib.linkextractors import LinkExtractor

from scrapy.contrib.spiders import CrawlSpider, Rule

 

class ArticleSpider(CrawlSpider):

    name = 'articles'

    allowed_domains = ['wikipedia.org']

    start_urls = ['https://en.wikipedia.org/wiki/'

        'Benevolent_dictator_for_life']

    rules = [Rule(LinkExtractor(allow=r'.*'), callback='parse_items',

        follow=True)]

 

    def parse_items(self, response):

        url = response.url

        title = response.css('h1::text').extract_first()

        text = response.xpath('//div[@id="mw-content-text"]//text()'

            ).extract()

        lastUpdated = response.css('li#footer-info-lastmod::text'

            ).extract_first()

        lastUpdated = lastUpdated.replace(

            'This page was last edited on ', '')

        print('URL is: {}'.format(url))

        print('title is: {} '.format(title))

        print('text is: {}'.format(text))

        print('Last updated: {}'.format(lastUpdated))

Этот новый «паук» ArticleSpider является наследником класса CrawlSpider. Вместо функции start_requests он предоставляет списки start_urls и allowed_domains, которые сообщают «пауку», с чего начать обход сети и следует ли переходить по ссылке или игнорировать ее, в зависимости от имени домена.

Предоставляется и список rules, в котором содержатся дальнейшие инструкции: по каким ссылкам переходить, а какие — игнорировать (в данном случае с помощью регулярного выражения .* мы разрешаем переходить по всем URL).

Помимо заголовка и URL на каждой странице, здесь добавлено извлечение еще пары элементов. С помощью селектора XPath извлекается текстовый контент каждой страницы. XPath часто используется для извлечения текста, включая содержимое дочерних тегов (например, тега <a>, расположенного внутри текстового блока). Если для этого использовать CSS-селектор, то контент всех дочерних тегов будет игнорироваться.

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

Чтобы запустить этот пример, нужно перейти в каталог wikiSpider/wikiSpider и выполнить следующую команду:

$ scrapy runspider articles.py

Предупреждение: он будет работать вечно

Этот «паук», как и предыдущий, запускается из командной строки. Однако он не прекратит работу (по крайней мере, будет работать очень, очень долго), пока вы не остановите его выполнение, нажав Ctrl+C или закрыв окно терминала. Прошу, пожалейте сервер «Википедии», не выполняйте данную программу слишком долго.

При запуске этот «паук» проходит по сайту wikipedia.org, переходя по всем ссылкам домена wikipedia.org, выводя заголовки страниц и игнорируя все внешние ссылки (ведущие на другие сайты):

2018-01-21 01:30:36 [scrapy.spidermiddlewares.offsite]

DEBUG: Filtered offsite request to 'www.chicagomag.com':

<GET http://www.chicagomag.com/Chicago-Magazine/June-2009/

Street-Wise/>

2018-01-21 01:30:36 [scrapy.downloadermiddlewares.robotstxt]

DEBUG: Forbidden by robots.txt: <GET https://en.wikipedia.org/w/

index.php?title=Adrian_Holovaty&action=edit&section=3>

title is: Ruby on Rails

URL is: https://en.wikipedia.org/wiki/Ruby_on_Rails

text is: ['Not to be confused with ', 'Ruby (programming language)',

'.', '\n', '\n', 'Ruby on Rails', ... ]

Last updated: 9 January 2018, at 10:32.

Пока данный веб-краулер работает неплохо, однако у него есть несколько ограничений. Вместо того чтобы посещать только страницы статей «Википедии», он вполне может переходить на страницы, не относящиеся к статьям, такие как эта:

title is: Wikipedia:General disclaimer

Рассмотрим внимательнее следующую строку кода, где используются объекты Scrapy Rule и LinkExtractor:

rules = [Rule(LinkExtractor(allow=r'.*'), callback='parse_items',

    follow=True)]

В данной строке содержится список объектов Scrapy Rule, определяющих правила, по которым фильтруются все найденные ссылки. При наличии нескольких правил каждая ссылка проверяется на соответствие им в порядке их перечисления. Первое подходящее правило — то, которое используется для определения способа обработки ссылки. Если ссылка не соответствует ни одному из правил, то игнорируется.

Правило может быть описано следующими четырьмя аргументами:

link_extractor — единственный обязательный аргумент — объект LinkExtractor;

• callback — функция обратного вызова, которая должна использоваться для анализа контента страницы;

• cb_kwargs — словарь аргументов, передаваемых в функцию обратного вызова. Имеет вид {arg_name1:arg_value1,arg_name2:arg_value2} и может быть удобным инструментом для многократного использования одних и тех же функций синтаксического анализа в незначительно различающихся задачах;

• follow — указывает на то, хотите ли вы, чтобы веб-краулер обработал также страницы по ссылкам, найденным на данной странице. Если функция обратного вызова не указана, то по умолчанию используется значение True (в конце концов, при отсутствии действий с этой страницей логично предположить, что вы по крайней мере захотите применить ее для продолжения сбора данных с сайта). Если предусмотрена функция обратного вызова, то по умолчанию используется значение False.

Простой класс LinkExtractor предназначен исключительно для распознавания и возврата ссылок, обнаруженных в HTML-контенте страницы на основе предоставленных этому классу правил. Имеет ряд аргументов, которые можно использовать для того, чтобы принимать или отклонять ссылки на основе CSS-селекторов и XPath, тегов (можно искать ссылки не только в тегах <a>!), доменов и др.

От класса LinkExtractor можно унаследовать свой, в котором можно добавить нужные аргументы. Подробнее об извлечении ссылок см. в документации Scrapy (https://doc.scrapy.org/en/latest/topics/link-extractors.html).

Несмотря на всю гибкость свойств класса LinkExtractor, самыми распространенными аргументами, которые вы, скорее всего, будете использовать, являются следующие:

allow — разрешить проверку всех ссылок, соответствующих заданному регулярному выражению;

• deny — запретить проверку всех ссылок, соответствующих заданному регулярному выражению.

Используя два отдельных класса Rule и LinkExtractor с общей функцией синтаксического анализа, можно создать «паука», который бы собирал данные в «Википедии», идентифицируя все страницы статей и помечая остальные страницы флагом (articleMoreRules.py):

from scrapy.contrib.linkextractors import LinkExtractor

from scrapy.contrib.spiders import CrawlSpider, Rule

 

class ArticleSpider(CrawlSpider):

    name = 'articles'

    allowed_domains = ['wikipedia.org']

    start_urls = ['https://en.wikipedia.org/wiki/'

        'Benevolent_dictator_for_life']

    rules = [

        Rule(LinkExtractor(allow='^(/wiki/)((?!:).)*$'),

            callback='parse_items', follow=True,

            cb_kwargs={'is_article': True}),

        Rule(LinkExtractor(allow='.*'), callback='parse_items',

            cb_kwargs={'is_article': False})

    ]

 

    def parse_items(self, response, is_article):

        print(response.url)

        title = response.css('h1::text').extract_first()

        if is_article:

            url = response.url

            text = response.xpath('//div[@id="mw-content-text"]'

                '//text()').extract()

            lastUpdated = response.css('li#footer-info-lastmod'

                '::text').extract_first()

            lastUpdated = lastUpdated.replace('This page was '

                'last edited on ', '')

            print('Title is: {} '.format(title))

            print('title is: {} '.format(title))

            print('text is: {}'.format(text))

        else:

            print('This is not an article: {}'.format(title))

Напомню, что правила применяются к каждой ссылке в том порядке, в котором они представлены в списке. Все страницы статей (страницы, URL которых начинается с /wiki/ и не содержит двоеточий) передаются в функцию parse_items с аргументом is_article=True. Все остальные ссылки (не на статьи) передаются в функцию parse_items с аргументом is_article=False.

Конечно, если вы хотите собирать информацию только со страниц статей и игнорировать все остальные, то такой подход был бы непрактичным. Намного проще игнорировать все страницы, которые не соответствуют структуре URL страниц со статьями, и вообще не определять второе правило (и переменную is_article). Однако бывают случаи, когда подобный подход может быть полезен — например, если информация из URL или данные, собранные при крау­линге, влияют на способ синтаксического анализа страницы.

Создание объектов Item

Мы уже рассмотрели множество способов поиска, синтаксического анализа и краулинга сайтов с помощью Scrapy, однако эта платформа также предоставляет полезные инструменты для упорядочения собранных элементов и их хранения в созданных пользователем объектах с четко определенными полями.

Чтобы упорядочить всю собираемую информацию, нужно создать объект Article. Определим этот новый элемент с именем Article в файле items.py.

Когда мы впервые открываем файл items.py, он выглядит так:

# -*- coding: utf-8 -*-

 

# Здесь определяются модели объектов, для которых выполняется веб-скрапинг.

#

# Подробнее см. документацию:

# http://doc.scrapy.org/en/latest/topics/items.html

 

import scrapy

 

class WikispiderItem(scrapy.Item):

    # Определяем поля для ваших объектов, как здесь:

    # name = scrapy.Field()

    pass

Заменим этот предоставляемый по умолчанию код-заглушку Item на новый класс Article, который является расширением scrapy.Item:

import scrapy

 

class Article(scrapy.Item):

    url = scrapy.Field()

    title = scrapy.Field()

    text = scrapy.Field()

    lastUpdated = scrapy.Field()

Мы определили три поля, в которые будут собираться данные с каждой страницы: заголовок, URL и дату последнего редактирования страницы.

Если мы собираем данные для страниц разных типов, то нужно определить каждый тип как отдельный класс в items.py. Если собираемые объекты Item имеют очень большие размеры или если мы захотим перенести в их Item дополнительные функции синтаксического анализа, то можно разместить каждый такой объект в отдельном файле. Однако, поскольку наши объекты Item невелики, мне нравится хранить их в одном файле.

Обратите внимание на изменения, которые были внесены в класс ArticleSpider в файле articleItems.py, чтобы можно было создать новый объект Article:

from scrapy.contrib.linkextractors import LinkExtractor

from scrapy.contrib.spiders import CrawlSpider, Rule

from wikiSpider.items import Article

 

class ArticleSpider(CrawlSpider):

    name = 'articleItems'

    allowed_domains = ['wikipedia.org']

    start_urls = ['https://en.wikipedia.org/wiki/Benevolent'

        '_dictator_for_life']

    rules = [

        Rule(LinkExtractor(allow='(/wiki/)((?!:).)*$'),

        callback='parse_items', follow=True),

    ]

 

    def parse_items(self, response):

        article = Article()

        article['url'] = response.url

        article['title'] = response.css('h1::text').extract_first()

        article['text'] = response.xpath('//div[@id='

            '"mw-content-text"]//text()').extract()

        lastUpdated = response.css('li#footer-info-lastmod::text'

            ).extract_first()

        article['lastUpdated'] = lastUpdated.replace('This page was '

            'last edited on ', '')

        return article

При запуске этого файла с помощью команды:

$ scrapy runspider articleItems.py

получим обычные отладочные данные Scrapy, а также список всех объектов статей в виде словаря Python:

2018-01-21 22:52:38 [scrapy.spidermiddlewares.offsite] DEBUG:

Filtered offsite request to 'wikimediafoundation.org':

<GET https://wikimediafoundation.org/wiki/Terms_of_Use>

2018-01-21 22:52:38 [scrapy.core.engine] DEBUG: Crawled (200)

<GET https://en.wikipedia.org/wiki/Benevolent_dictator_for_life

#mw-head> (referer: https://en.wikipedia.org/wiki/Benevolent_

dictator_for_life)

2018-01-21 22:52:38 [scrapy.core.scraper] DEBUG: Scraped from

<200 https://en.wikipedia.org/wiki/Benevolent_dictator_for_life>

{'lastUpdated': ' 13 December 2017, at 09:26.',

'text': ['For the political term, see ',

         'Benevolent dictatorship',

         '.',

         ...

Использование объектов Item в Scrapy не только обеспечивает хорошую упорядоченность кода и представление объектов в удобно читаемом виде. У объектов Item есть множество инструментов для вывода и обработки данных, о которых пойдет речь в следующих разделах.

Вывод объектов Item

В Scrapy объекты Item позволяют определить, какие фрагменты информации из посещенных страниц следует сохранять. Scrapy может сохранять эту информацию различными способами, такими как файлы форматов CSV, JSON или XML, с помощью следующих команд:

$ scrapy runspider articleItems.py -o articles.csv -t csv

$ scrapy runspider articleItems.py -o articles.json -t json

$ scrapy runspider articleItems.py -o articles.xml -t xml

Каждая из этих команд запускает веб-скрапер articleItems и записывает полученные данные в заданном файле в указанном формате. Если такого файла еще не существует, то он будет создан.

Вы могли заметить, что в «пауке» ArticleSpider, созданном в предыдущих примерах, переменная текстовых данных представляет собой не одну строку, а их список. Каждая строка в нем соответствует тексту, расположенному в одном из элементов HTML, тогда как контент тега <divid="mw-content-text">, из которого мы собираем текстовые данные, представляет собой множество дочерних элементов.

Scrapy хорошо управляет этими усложненными значениями. Например, при сохранении данных в формате CSV списки преобразуются в строки, а запятые экранируются, так что список текстовых фрагментов в формате CSV занимает одну ячейку.

В XML каждый элемент этого списка сохраняется в отдельном дочернем теге:

<items>

<item>

    <url>https://en.wikipedia.org/wiki/Benevolent_dictator_for_life</url>

    <title>Benevolent dictator for life</title>

    <text>

        <value>For the political term, see </value>

        <value>Benevolent dictatorship</value>

        ...

    </text>

    <lastUpdated> 13 December 2017, at 09:26.</lastUpdated>

</item>

....

В формате JSON списки так и сохраняются в виде списков.

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

Динамический конвейер

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

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

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

Чтобы создать динамический конвейер, вернемся к файлу settings.py (см. начало главы). В нем содержатся следующие строки кода, заблокированные символами комментариев:

# Configure item pipelines

# See http://scrapy.readthedocs.org/en/latest/topics/item-pipeline.html

#ITEM_PIPELINES = {

#    'wikiSpider.pipelines.WikispiderPipeline': 300,

#}

Уберем символы комментариев из последних трех строк и получим следующее:

ITEM_PIPELINES = {

    'wikiSpider.pipelines.WikispiderPipeline': 300,

}

Таким образом, мы создали класс Python wikiSpider.pipelines.Wikispider­Pipe­line, который будет служить для обработки данных, а также указали целое число, соответствующее порядку запуска конвейера при наличии нескольких классов обработки. Здесь можно использовать любое целое число, однако обычно это цифры от 0 до 1000; конвейер запускается в порядке возрастания.

Теперь нужно добавить класс конвейера в «паука» и переписать нашего исходного «паука» таким образом, чтобы он собирал данные, а конвейер выполнял нелегкую задачу по их обработке. Было бы заманчиво создать в исходном «пауке» метод parse_items, который бы возвращал ответ, и позволить конвейеру создавать объект Article:

    def parse_items(self, response):

        return response

Однако фреймворк Scrapy такого не допускает: должен возвращаться объект Item (или Article, который является расширением Item). Итак, сейчас назначение parse_items состоит в том, чтобы извлечь необработанные данные и обработать по минимуму — лишь бы можно было передать их в конвейер:

from scrapy.contrib.linkextractors import LinkExtractor

from scrapy.contrib.spiders import CrawlSpider, Rule

from wikiSpider.items import Article

 

class ArticleSpider(CrawlSpider):

    name = 'articlePipelines'

    allowed_domains = ['wikipedia.org']

    start_urls = ['https://en.wikipedia.org/wiki/Benevolent_dictator_for_life']

    rules = [

        Rule(LinkExtractor(allow='(/wiki/)((?!:).)*$'),

            callback='parse_items', follow=True),

    ]

 

    def parse_items(self, response):

        article = Article()

        article['url'] = response.url

        article['title'] = response.css('h1::text').extract_first()

        article['text'] = response.xpath('//div[@id='

            '"mw-content-text"]//text()').extract()

        article['lastUpdated'] = response.css('li#'

            'footer-info-lastmod::text').extract_first()

        return article

Этот файл хранится в репозитории GitHub под именем articlePipelines.py.

Конечно, теперь нужно связать файл pipelines.py с измененным «пауком», добавив конвейер. При первоначальной инициализации проекта Scrapy был создан файл wikiSpider/wikiSpider/pipelines.py:

# -*- coding: utf-8 -*-

 

# Define your item pipelines here

#

# Don't forget to add your pipeline to the ITEM_PIPELINES setting

# See: http://doc.scrapy.org/en/latest/topics/item-pipeline.html

 

class WikispiderPipeline(object):

    def process_item(self, item, spider):

        return item

Этот класс-заглушку следует заменить нашим новым кодом конвейера. В предыдущих разделах мы собирали два поля в необработанном формате — lastUpdated (плохо отформатированный строковый объект, представляющий дату) и text («грязный» массив строковых фрагментов), — но для них можно использовать дополнительную обработку.

Вместо кода-заглушки в wikiSpider/wikiSpider/pipelines.py поставим следующий код:

from datetime import datetime

from wikiSpider.items import Article

from string import whitespace

 

class WikispiderPipeline(object):

    def process_item(self, article, spider):

        dateStr = article['lastUpdated']

        article['lastUpdated'] = article['lastUpdated']

            .replace('This page was last edited on', '')

        article['lastUpdated'] = article['lastUpdated'].strip()

        article['lastUpdated'] = datetime.strptime(

        article['lastUpdated'], '%d %B %Y, at %H:%M.')

        article['text'] = [line for line in article['text']

            if line not in whitespace]

        article['text'] = ''.join(article['text'])

        return article

У класса WikispiderPipeline есть метод process_item, который принимает объект Article, преобразует строку lastUpdated в объект datetime Python, очищает текст и трансформирует его из списка строк в одну строку.

Метод process_item является обязательным для любого класса конвейера. В Scrapy этот метод используется для асинхронной передачи объектов Item, собранных «пауком». Анализируемый объект Article, который возвращается в данном случае, будет сохранен или выведен Scrapy, если, например, мы решим выводить элементы в формате JSON или CSV, как это было сделано в предыдущем разделе.

Теперь можно выбрать, где обрабатывать данные: в методе parse_items в «пауке» или в методе process_items в конвейере.

В файле settings.py можно создать несколько конвейеров, каждый из которых будет выполнять свою задачу. Однако Scrapy передает все элементы, независимо от их типа, во все конвейеры по порядку. Синтаксический анализ элементов конкретных типов лучше реализовать в «пауке» и только потом передавать данные в конвейер. Но если этот анализ занимает много времени, то можно переместить его в конвейер (где он будет выполняться асинхронно) и добавить проверку типа элемента:

def process_item(self, item, spider):

    if isinstance(item, Article):

        # обработка объектов Article

При написании проектов Scrapy, особенно крупных, важно учитывать, где и какую обработку вы собираетесь делать.

Ведение журнала Scrapy

Отладочная информация, генерируемая Scrapy, бывает полезна, однако, как вы могли заметить, часто чересчур многословна. Вы можете легко настроить уровень ведения журнала, добавив в файл settings.py проекта Scrapy следующую строку:

LOG_LEVEL = 'ERROR'

В Scrapy принята стандартная иерархия уровней ведения журнала:

CRITICAL;

• ERROR;

• WARNING;

• DEBUG;

• INFO.

В случае уровня ведения журнала ERROR будут отображаться только события типов CRITICAL и ERROR; при ведении журнала на уровне INFO будут отображаться все события и т.д.

Кроме управления ведением журнала через файл settings.py, также можно управлять тем, куда попадают записи из командной строки. Чтобы они выводились не на терминал, а в отдельный файл журнала, нужно указать этот файл при запуске Scrapy из командной строки:

$ scrapy crawl articles -s LOG_FILE=wiki.log

Эта команда создает в текущем каталоге новый файл журнала (если такого еще нет) и делает в него все журнальные записи, оставляя терминал только для данных, выводимых операторами Python, которые вы сами добавили в программу.

Дополнительные ресурсы

Scrapy — мощный инструмент, способный решить многие проблемы веб-краулинга. Он автоматически собирает все URL и проверяет их на соответствие заданным правилам; гарантирует, что все адреса являются уникальными, при необходимости нормализует относительные URL и рекурсивно переходит на страницы более глубоких уровней.

В этой главе мы лишь поверхностно рассмотрели возможности Scrapy; я призываю вас заглянуть в документацию по Scrapy (https://doc.scrapy.org/en/latest/news.html), а также почитать книгу Learning Scrapy Димитриоса Кузис-Лукаса (Dimitrios Kouzis-Loukas) (издательство O’Reilly) (http://shop.oreilly.com/product/9781784399788.do), в которой содержится исчерпывающее описание данного фреймворка.

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