Веб скрапинг (парсинг) данных

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

Содержание
  1. Что такое веб-скрапинг?
  2. Почему веб-скрапинг?
  3. Ваш первый веб-скрапер
  4. Подключение
  5. Разбор html файла с помощью BeautifulSoap
  6. Обработка ошибок при скрапинге
  7. Продвинутый HTML парсинг
  8. Еще немного о BeautifulSoup
  9. find() и find_all() с BeautifulSoup
  10. Ключевой аргумент и класс
  11. Другие объекты BeautifulSoup
  12. Навигация по деревьям
  13. Работа с детьми в дереве тегов и другими потомками
  14. Обработка соседних элементов
  15. Работа с родителями
  16. Использование регулярных выражений в парсинге
  17. Регулярные выражения и BeautifulSoup
  18. Доступ к атрибутам
  19. Лямбда-выражения
  20. Написание Веб-пауков
  21. Перемещение по одному домену
  22. Поиск по всему сайту
  23. Тёмная и глубокая Сети (даркнет и дипнет)
  24. Сбор данных по всему сайту
  25. Редиректы
  26. Краулинг множества сайтов в интернете
  27. Модели веб-сканеров
  28. Планирование и определение объектов
  29. Структурирование сканеров
  30. Прохождение по сайтам через поиск
  31. Индексация нескольких типов страниц
  32. Размышления о моделях веб-скрейпера
  33. Scrapy
  34. Установка Scrapy
  35. Инициализация нового паука
  36. Написание простого скрейпера
  37. Пауки с правилами
  38. Создание объектов
  39. Сохранение данных в Scrapy
  40. Пайплайн элементов
  41. Журналирование (логи) в Scrapy
  42. Дальнейшее изучение Scrapy
  43. Хранение данных
  44. Медиафайлы
  45. Хранение данных в формате CSV
  46. MySQL
  47. Установка MySQL
  48. Несколько базовых команд

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

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

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

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

Почему веб-скрапинг?

Вы можете спросить: «Разве сбор данных не для этого служат API?»

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

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

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

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

Ваш первый веб-скрапер

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

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

Подключение

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

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

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

  1. Компьютер Боба отправляет поток битов 1 и 0, указанных высокими и низкими напряжениями на проводе. Эти биты формируют какую-то информацию, содержащую заголовок и тело. Заголовок содержит непосредственное направление места назначения его локального MAC-адреса маршрутизатора, с конечным пунктом назначения IP-адреса Алисы. Тело содержит его запрос для серверного приложения Алисы.
  2. Локальный маршрутизатор Боба получает все эти единицы и нули и интерпретирует их как пакет, от собственного MAC-адреса Боба, предназначенный для IP-адреса Алисы. Его маршрутизатор накладывает свой собственный IP-адрес на пакет в качестве «откуда» IP-адреса и отправляет его через интернет.
  3. Пакет Боба проходит через несколько промежуточных серверов, которые направляют его по правильному физическому/проводному пути, к серверу Алисы.
  4. Сервер Алисы получает пакет по своему IP-адресу.
  5. Сервер Алисы читает портовое назначение пакета в заголовке и передает его соответствующему приложению — веб-серверному приложению. (Портовое назначение пакета почти всегда порт 80 для веб-приложений; это можно представить как номер квартиры для данных пакетов, в то время как IP-адрес подобен улице.)
  6. Веб-серверное приложение получает поток данных от процессора сервера. Эти данные что-то говорят вроде следующего:
  • Это GET-запрос.
  • Запрашивается следующий файл: index.html.
  1. Веб-сервер находит правильный HTML-файл, упаковывает его в новый пакет для отправки Бобу и отправляет его через свой локальный маршрутизатор для передачи обратно на компьютер Боба, через тот же процесс.

И вот мы имеем Интернет.

Так где в этом обмене вступает в игру веб-браузер? Абсолютно нигде. Фактически, браузеры являются относительно недавним изобретением в истории интернета, учитывая, что Nexus был выпущен в 1990 году.

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

from urllib.request import urlopen
html = urlopen('http://pythonscraping.com/pages/page1.html')
print(html.read())

Чтобы выполнить этот код, вы можете сохранить его локально как scrapetest.py и запустить его в вашем терминале, используя эту команду:

$ python scrapetest.py

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

Почему важно начать думать об этих адресах как о «файлах», а не о «страницах»? Большинство современных веб-страниц имеют множество файлов ресурсов, связанных с ними. Это могут быть файлы изображений, файлы JavaScript, файлы CSS или любой другой контент, на который ссылается запрашиваемая вами страница. Когда веб-браузер сталкивается с тегом, таким как <img src="cuteKitten.jpg">, браузер знает, что ему нужно сделать еще один запрос к серверу, чтобы получить данные из файла cuteKitten.jpg для полного отображения страницы для пользователя. Конечно, ваш сценарий Python не имеет логики для возврата и запроса нескольких файлов (пока); он может только читать один HTML-файл, который вы напрямую запросили.

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

Разбор html файла с помощью BeautifulSoap

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

Самым часто используемым объектом в библиотеке 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. Его можно установить через pip:

pip3 install lxml

lxml можно использовать с BeautifulSoup, изменив предоставляемую строку парсера:

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

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

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

Еще одним популярным парсером HTML является html5lib. Как и lxml, html5lib является крайне лояльным парсером, который еще более активно исправляет сломанный HTML. Он также зависит от внешней зависимости и медленнее, чем lxml и html.parser. Несмотря на это, он может быть хорошим выбором, если вы работаете с беспорядочными или написанными вручную веб-сайтами.

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

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

Обработка ошибок при скрапинге

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

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

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

В этой строке могут произойти две основные проблемы:

  • Страница не найдена на сервере (или возникла ошибка при ее получении).
  • Сервер не найден.

В первой ситуации будет возвращена ошибка HTTP. Эта ошибка HTTP может быть «404 Страница не найдена», «500 Внутренняя ошибка сервера» и так далее. Во всех этих случаях функция 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)
    # вернуть null, выйти или выполнить другое "План B"
else:
    # программа продолжает работать. Примечание: если вы вернетесь или выйдете из
    # исключения, вам не нужно использовать оператор "else"

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

Если сервер вообще не найден (например, если http://www.pythonscraping.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('Сервер не может быть найден!')
else:
    print('Ура!')

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

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

print(bs.nonExistentTag)

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

print(bs.nonExistentTag.someTag)

Это вызывает исключение:

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

Так как же можно защититься от этих двух ситуаций? Самый простой способ — явно проверять обе ситуации:

try:
    badContent = bs.nonExistingTag.anotherTag
except AttributeError as e:
    print('Тег не был найден')
else:
    if badContent == None:
        print ('Тег не был найден')
    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('Заголовок не найден')
else:
    print(title)

Продвинутый HTML парсинг

Когда Микеланджело спросили, как ему удалось создать такое мастерское произведение, как его Давид, он, как сообщается, сказал: «Это легко. Просто отсекайте куски камня, которые не похожи на Давида.»

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

Вам не всегда нужен молоток

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

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

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

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

Так какие у вас варианты?

  • Ищите ссылку «Распечатать эту страницу» или, возможно, мобильную версию сайта, которая имеет лучше отформатированный HTML (больше о том, как представить себя как мобильное устройство — и получить мобильные версии сайтов — в главе 14).
  • Ищите информацию, скрытую в файле JavaScript. Помните, что вам может понадобиться изучить импортированные файлы JavaScript, чтобы сделать это. Например, однажды я собрал адреса (вместе с широтой и долготой) с сайта в красиво отформатированный массив, посмотрев на JavaScript для встроенной карты Google, которая отображала точку над каждым адресом.
  • Это более распространено для заголовков страниц, но информация может быть доступна в URL-адресе самой страницы.
  • Если информация, которую вы ищете, уникальна для этого веб-сайта по какой-то причине, вы ничего не сможете сделать. В противном случае попробуйте подумать о других источниках, откуда можно получить эту информацию. Есть ли другой сайт с теми же данными? Этот сайт отображает данные, которые он извлек или агрегировал с другого сайта? Важно не просто начинать копать и попадать в ловушку, из которой вам может не удастся выбраться, особенно когда столкнулись с зарытыми или плохо отформатированными данными. Дышите глубже и думайте о альтернативах.

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

Еще немного о BeautifulSoup

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

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

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

Другие могут выглядеть так:

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

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

Давайте создадим пример веб-скрейпера, который анализирует страницу, расположенную по адресу 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.

Вы можете получить всю страницу и создать с помощью 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 для извлечения списка существительных, найденных, выбрав только текст внутри тегов <span class=»green»></span> (функция find_all является крайне гибкой функцией, которую вы будете часто использовать позже в этой статье):

nameList = bs.findAll('span', {'class':'green'})
for name in nameList:
    print(name.get_text())

При выполнении этот код должен перечислить все существительные в тексте в том порядке, в котором они появляются в «Войне и мире». Что происходит здесь? Ранее вы вызывали bs.tagName, чтобы получить первое вхождение этого тега на странице. Теперь вы вызываете bs.find_all(tagName, tagAttributes), чтобы получить список всех тегов на странице, а не только первый. После получения списка имен программа перебирает все имена в списке и печатает 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. Например, следующий код вернет список всех тегов заголовков в документе:

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

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

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

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

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

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

Вывод этого кода — 7.

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

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

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

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

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

Ключевой аргумент и класс

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

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

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

Кроме того, иногда возникают проблемы при использовании ключевого слова, особенно при поиске элементов по их атрибуту class, потому что class является защищенным ключевым словом в Python. То есть, class — это зарезервированное слово в Python, которое не может быть использовано в качестве имени переменной или аргумента (нет отношения к ключевому аргументу 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 …). Если у вас есть длинный список тегов, вы можете получить много ненужной информации. Аргумент ключевого слова позволяет добавить дополнительный «и» фильтр к этому.

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

До этого в книге вы видели два типа объектов в библиотеке BeautifulSoup:

  • Объекты BeautifulSoup. Экземпляры, которые вы видели в предыдущих примерах кода под переменной bs
  • Объекты тегов. Полученные в списках или полученные индивидуально, вызывая find и find_all для объекта BeautifulSoup, или продвигаясь вниз, как показано ниже: bs.div.h1

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

  • Объекты NavigableString. Используются для представления текста внутри тегов, а не самих тегов (некоторые функции работают с и создают NavigableStrings, а не объекты тегов).
  • Объекты Comment. Используются для поиска HTML-комментариев в тегах комментариев, <!—как этот—>

Эти четыре объекта — единственные объекты, с которыми вы когда-либо столкнетесь в библиотеке BeautifulSoup (на момент написания этого текста).

Функция find_all отвечает за поиск тегов по их имени и атрибутам. Но что если вам нужно найти тег по его местоположению в документе? В этом случае пригодится навигация по дереву. В предыдущем разделе вы изучили навигацию по дереву BeautifulSoup в одном направлении: bs.tag.subTag.anotherSubTag

Теперь давайте рассмотрим навигацию вверх, вдоль и по диагонали через HTML-деревья. В качестве примерной страницы для извлечения информации вы будете использовать наш весьма сомнительный интернет-магазин на 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. Он не найдет теги, находящиеся за пределами тела документа.

Аналогично 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, включая начальную строку с заголовками столбцов. Если бы вы написали его, используя функцию .descendants() вместо .children(), то было бы найдено и выведено около двух десятков тегов внутри таблицы, включая теги img, span и отдельные теги td. Очень важно различать между детьми и потомками!

Обработка соседних элементов

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

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-страницы с целью их анализа, вы начинаете с верхнего слоя тегов, а затем выясняете, как проникнуть в нужную часть данных. Однако иногда может возникнуть необычная ситуация, в которой потребуются функции поиска родителей BeautifulSoup, .parent и .parents. Например:

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.00). Как это работает? Следующая диаграмма представляет древовидную структуру части HTML-страницы, с которой вы работаете, с пронумерованными шагами:

<tr>
— <td>
— <td>
— <td>
— "$15.00"
— <td>
— <img src="../img/gifts/img1.jpg">
  1. Сначала выбирается тег изображения с src="../img/gifts/img1.jpg".
  2. Вы выбираете родителя этого тега (в данном случае тег td).
  3. Вы выбираете предыдущего соседа тега td (в данном случае тег td, который содержит стоимость товара).
  4. Вы выбираете текст внутри этого тега, «$15.00».

Использование регулярных выражений в парсинге

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

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

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

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

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

  1. Напишите букву a как минимум один раз.
  2. Добавьте к этому букву b ровно пять раз.
  3. Добавьте к этому букву c любое четное количество раз.
  4. Напишите в конце либо букву d, либо e.

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

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

aabbbbb(cc)(d|e)

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

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

bbbbb Здесь нет никаких особых эффектов — просто пять b подряд.

(cc)* Любое четное количество вещей может быть сгруппировано парами, поэтому, чтобы выполнить это правило о четном количестве вещей, вы можете написать две c, заключить их в скобки и добавить звездочку после этого, что означает, что у вас может быть любое количество пар c (заметьте, что это может означать и 0 пар).

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

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

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

Символ(ы) Значение Пример Пример соответствия
* Соответствует предшествующему символу, подвыражению или символу в квадратных скобках, 0 или более раз. ab aaaaaaaa, aaabbbbb, bbbbbb
+ Соответствует предшествующему символу, подвыражению или символу в квадратных скобках, 1 или более раз. a+b+ aaaaaaaab, aaabbbbb, abbbbbb
[] Соответствует любому символу в пределах скобок (т.е. «Выберите любой из этих элементов»). [A-Z]* APPLE, CAPITALS, QWERTY
() Группированное подвыражение (оно вычисляется первым, в «порядке операций» регулярных выражений). (ab) aaabaab, abaaab, ababaaaaab
{m, n} Соответствует предшествующему символу, подвыражению или символу в квадратных скобках от m до n раз (включительно). a{2,3}b{2,3} aabbb, aaabbb, aabb
[^] Соответствует любому одиночному символу, который не находится в скобках. [^A-Z]* apple, lowercase, qwerty
| Соответствует любому символу, строке символов или подвыражению, разделенным (обратите внимание, что это вертикальная черта, или палка, а не заглавная i). b(a
. Соответствует любому одиночному символу (включая символы, цифры, пробел и т. д.). b.d bad, bzd, b$d, b d
^ Указывает, что символ или подвыражение находится в начале строки. ^a apple, asdf, a
\ Экранирующий символ (позволяет использовать специальные символы как их буквальные значения). . | \ .
$ Часто используется в конце регулярного выражения, означает «совпадение с этим до конца строки». Без него каждое регулярное выражение имеет де факто «.*» в конце, принимая строки, в которых совпадает только первая часть строки. Это можно рассматривать как аналог символа ^.
?! «Не содержит». Эта странная пара символов, непосредственно предшествующая символу (или регулярному выражению), указывает на то, что этот символ не должен находиться в этом конкретном месте в большей строке. Это может быть сложно использовать; в конце концов, символ может быть найден в другой части строки. Если вы пытаетесь полностью исключить символ, используйте его в сочетании с ^ и $ с обеих сторон. ^((?![A-Z]).)*$ no-caps-here, $ymb0ls a4e fine

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

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

[A-Za-z0-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=»aTagIdHere»)), также могут принимать и регулярное выражение без проблем.

Давайте рассмотрим несколько примеров, извлекая данные со страницы по адресу 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, где URL, на который он указывает, содержится в атрибуте href; или тег 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, вы можете даже объединить их с регулярными выражениями, чтобы найти теги с атрибутом, соответствующим определенному шаблону строки.

Написание Веб-пауков

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

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

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

Перемещение по одному домену

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

Например, Эрик Айдл снялся в «Дадли-Ду-Райт» вместе с Бренданом Фрейзером, который снялся в «Воздух, который я дышу» вместе с Кевином Бэконом. В этом случае цепочка от Эрика Айдла до Кевина Бэкона состоит всего из трех объектов.

В этом разделе вы начнете проект, который станет решателем задачи «Шести степеней Википедии»: вы сможете взять страницу Эрика Айдла и найти минимальное количество кликов по ссылкам, которые приведут вас на страницу Кевина Бэкона.

Но что насчет нагрузки на серверы Википедии?

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

Также имейте в виду, что если вы планируете большой проект, связанный с данными из Википедии, вам следует проверить, доступны ли эти данные уже через API Википедии. Википедия часто используется в качестве веб-сайта для демонстрации скрейперов и веб-пауков, потому что у нее простая структура 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 с идентификатором bodyContent.
  • URL не содержат двоеточий.
  • URL начинаются с /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 работает на алгоритме Мерсенна Твистер. Хотя он создает случайные числа, которые трудно предсказать и равномерно распределены, он немного интенсивно использует процессор. Такие хорошие случайные числа не дешевы!

Затем программа определяет функцию 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 не могут попасть.

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

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

Генерация карты сайта

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

Сбор данных

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

Очевидно, что эта ситуация может быстро выйти из-под контроля. Если на каждой странице есть 10 внутренних ссылок, а веб-сайт имеет глубину в 5 страниц (довольно типичная глубина для среднего сайта), то количество страниц, которые вам нужно просканировать, составит 10^5, или 100 000 страниц, прежде чем вы сможете быть уверены, что исчерпывающе охватили веб-сайт. Интересно, что хотя «5 страниц глубиной и 10 внутренних ссылок на страницу» являются достаточно типичными размерами для веб-сайта, очень мало веб-сайтов имеют 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, но страницы загрузки файлов, страницы обсуждения и подобные содержат двоеточия в URL.

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

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

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

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

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

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

Сбор данных по всему сайту

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

Как всегда, первый шаг — определить, как лучше всего это сделать, — это изучить несколько страниц сайта и выявить закономерность. Изучив несколько страниц Википедии (как статьи, так и страницы, не являющиеся статьями, например, страница с политикой конфиденциальности), следующее становится ясным: • Все заголовки (на всех страницах, независимо от их статуса как страницы статьи, страницы истории редактирования или любой другой страницы) имеются под тегами h1 → span, и это единственные теги h1 на странице. • Как упоминалось ранее, весь текст контента находится под тегом div#bodyContent. Однако, если вы хотите быть более конкретным и получить только первый абзац текста, вам может быть лучше использовать 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('На этой странице что-то отсутствует! Продолжаем.')
    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 появляется на каждой странице (по крайней мере, насколько я могу судить), поэтому первым извлекается эта информация. Текстовый контент появляется на большинстве страниц (за исключением страниц файлов), поэтому это вторая получаемая информация. Кнопка Редактировать появляется только на страницах, где уже есть и заголовок, и текстовый контент, но она не появляется на всех таких страницах.

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

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

Редиректы

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

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

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

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

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

Дополнительную информацию о редиректах на стороне клиента, которые выполняются с использованием JavaScript или HTML, см. в разделе 12.

Краулинг множества сайтов в интернете

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

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

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

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

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

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

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

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

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

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", но не содержат текущий 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 и случайным образом переходит от внешней ссылки к внешней ссылке. Вот пример вывода, который он создает:

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

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

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

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

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

# Собирает список всех внешних 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)

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

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

Схема алгоритма для веб-сканера сайта, собирающего все внешние ссылки

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

Модели веб-сканеров

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

Вас могут попросить собрать новостные статьи или блоги с различных веб-сайтов, каждый из которых имеет разные шаблоны и макеты. Тег h1 на одном веб-сайте содержит заголовок статьи, на другом — заголовок самого сайта, а заголовок статьи находится в <span id=»title»>. Вам может понадобиться гибкий контроль над тем, какие веб-сайты сканируются и как именно они сканируются, а также способ быстро добавлять новые веб-сайты или изменять существующие, насколько это возможно, без написания множества строк кода. Вас могут попросить собирать цены на товары с различных веб-сайтов с целью сравнения цен на один и тот же товар. Возможно, эти цены указаны в разных валютах, и, возможно, вам также потребуется объединить это с внешними данными из какого-то другого источника, не связанного с вебом.

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

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

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

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

  • Название продукта
  • Цена
  • Описание
  • Размеры
  • Цвета
  • Тип ткани
  • Рейтинг клиентов

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

  • Артикул товара

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

• Твердый/мягкий переплет

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

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

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

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

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

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

  • Название продукта
  • Производитель
  • Номер идентификации продукта (если доступно/необходимо)

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

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

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

  • Являются ли эти данные рассеянными или плотными?
  • Будут ли они актуальными и заполненными в каждом списке или только в нескольких из набора?
  • Каков объем данных?
  • Особенно в случае больших данных, мне нужно будет регулярно получать их каждый раз при запуске анализа или только по случаю?
  • Насколько переменен этот тип данных?
  • Мне регулярно придется добавлять новые атрибуты, изменять типы (например, узоры тканей, которые могут часто добавляться), или они фиксированы (размеры обуви)?

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

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

  • Название продукта
  • Производитель
  • Номер идентификации продукта (если доступно/релевантно)
  • Атрибуты (необязательный список или словарь)

И тип атрибута, который выглядит так:

  • Название атрибута
  • Значение атрибута

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

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

  • Идентификатор продукта
  • Идентификатор магазина
  • Цена
  • Дата/Время, когда была найдена цена

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

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

И каждая цена будет выглядеть следующим образом:

  • Идентификатор экземпляра продукта
  • Идентификатор магазина
  • Цена
  • Дата/Время, когда была найдена цена

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

Если вы сканируете новостные статьи, вам могут понадобиться базовые сведения, такие как:

  • Название
  • Автор
  • Дата
  • Содержание

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

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

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

Одно из самых впечатляющих достижений поисковой системы, такой как Google, заключается в том, что она умудряется извлекать актуальные и полезные данные из различных веб-сайтов, не имея заранее известной информации о структуре веб-сайта самого по себе. Хотя мы, люди, можем немедленно определить заголовок и основное содержание страницы (исключая случаи крайне плохого веб-дизайна), гораздо сложнее заставить бота сделать то же самое. К счастью, в большинстве случаев веб-сканирование не подразумевает сбор данных с веб-сайтов, которые вы никогда ранее не видели, а с нескольких или десятков веб-сайтов, предварительно отобранных человеком. Это означает, что вам не нужно использовать сложные алгоритмы или машинное обучение для определения того, какой текст на странице «больше похож на заголовок», или какой, вероятно, «основное содержание». Вы можете определить эти элементы вручную. Самый очевидный подход заключается в написании отдельного веб-сканера или парсера страницы для каждого веб-сайта. Каждый из них может принимать URL, строку или объект BeautifulSoup и возвращать объект Python для того, что было сканировано. Вот пример класса Content (представляющего кусок контента на веб-сайте, такой как новостная статья) и двух функций скрейпера, которые принимают объект BeautifulSoup и возвращают экземпляр Content:

import requests
from bs4 import BeautifulSoup

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.find_all("p", {"class":"story-content"})
    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, используемые для получения каждой части информации. Функции find и find_all BeautifulSoup принимают два аргумента — строку тега и словарь атрибутов со значениями, поэтому вы можете передавать эти аргументы в качестве параметров, определяющих структуру самого сайта и местоположение целевых данных.

Для большего удобства вместо работы со всеми этими аргументами тегов и пар ключ/значений вы можете использовать функцию select BeautifulSoup с одиночным строковым селектором 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 вы можете написать веб-сканер для извлечения заголовка и содержимого любого 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):
        """
        Утилитарная функция, используемая для извлечения строки содержания из объекта Beautiful Soup и селектора. 
        Возвращает пустую строку, если для заданного селектора объект не найден
        """
        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', 'p.story-content']
]
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 для каждого нового веб-сайта, представьте, что происходит, когда вы переходите от системы с 4 источниками веб-сайтов к системе с 20 или 200 источниками. Каждый список строк относительно легко написать. Он не занимает много места. Его можно загрузить из базы данных или CSV-файла. Его можно импортировать из удаленного источника или передать не программисту с опытом работы на frontend, чтобы он заполнил и добавил новые веб-сайты, и им никогда не придется смотреть на строку кода.

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

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

Структурирование сканеров

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

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

Прохождение по сайтам через поиск

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

  • Большинство сайтов извлекают список результатов поиска по определенной теме, передавая эту тему в виде строки через параметр в URL-адресе. Например: http://example.com?search=myTopic. Первая часть этого URL-адреса может быть сохранена как свойство объекта Website, а тема просто добавляется к нему.
  • После поиска большинство сайтов представляют полученные страницы в виде легко идентифицируемого списка ссылок, обычно с удобным окружающим тегом, таким как <span class=»result»>, точный формат которого также может быть сохранен как свойство объекта Website.
  • Каждая ссылка на результат либо является относительным URL (например, /articles/page.html), либо абсолютным URL (например, 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("Найдена новая статья по теме: {}".format(self.topic))
        print("ЗАГОЛОВОК: {}".format(self.title))
        print("СОДЕРЖАНИЕ:\n{}".format(self.body))
        print("URL: {}".format(self.url))

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

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 был немного расширен и содержит наши данные о веб-сайтах, список тем для поиска и два цикла, которые перебирают все темы и все веб-сайты. Он также содержит функцию поиска, которая переходит на страницу поиска для определенного веб-сайта и темы и извлекает все 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):
        """
        Searches a given website for a given topic and records all pages found
        """
        bs = self.getPage(site.searchUrl + topic)
        searchResults = bs.select(site.resultListing)
        for result in searchResults:
            url = result.select(site.resultUrl)[0].attrs["href"]
            # Check to see whether it's a relative or an absolute 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()

class Content:
    """Common base class for all articles/pages"""
    def __init__(self, topic, url, title, body):
        self.topic = topic
        self.title = title
        self.body = body
        self.url = url
    
    def print(self):
        """
        Flexible printing function controls output
        """
        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))

class Website:
    """Contains information about website structure"""
    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 = 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://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)

Этот скрипт проходит через все темы в списке тем и анонсирует перед началом сканирования по теме: GETTING INFO ABOUT python Затем он проходит через все сайты в списке сайтов и сканирует каждый отдельный сайт для каждой конкретной темы. Каждый раз, когда он успешно извлекает информацию о странице, он выводит ее на консоль:

Найдена новая статья по теме: python URL: http://example.com/examplepage.html

ЗАГОЛОВОК: Заголовок страницы здесь

СОДЕРЖАНИЕ: Содержание страницы здесь

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

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

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

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

Эти типы краулеров не требуют структурированного метода нахождения ссылок, как в предыдущем разделе о краулинге по страницам поиска, поэтому атрибуты, описывающие страницу поиска, не требуются в объекте Веб-сайт. Однако, поскольку краулеру не предоставляются конкретные инструкции для местоположения/позиций ссылок, которые он ищет, вам нужны некоторые правила, чтобы сказать ему, какие именно страницы выбирать. Вы предоставляете 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))

Классы Website и 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):
        """
        Get pages from website home page
        """
        bs = self.getPage(self.site.url)
        targetPages = bs.findAll('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) в краулере, но означает, что для краулера придется создавать новый экземпляр для каждого сайта, а не переиспользовать один и тот же для перебора списка веб-сайтов.

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

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

Индексация нескольких типов страниц

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

К счастью, есть несколько основных способов идентификации типа страницы:

  • По URL-адресу Все блог-посты на веб-сайте могут содержать URL-адрес (например, http://example.com/blog/title-of-post).
  • По наличию или отсутствию определенных полей на сайте Если на странице есть дата, но нет имени автора, вы можете отнести ее к пресс-релизу. Если есть заголовок, основное изображение, цена, но нет основного контента, это может быть страницей товара.
  • По наличию определенных тегов на странице для идентификации страницы Вы можете воспользоваться тегами, даже если не собираете данные внутри тегов. Ваш краулер может искать элементы, такие как <div id=»related-products»>, чтобы определить страницу как страницу товара, даже если краулер не интересуется содержимым связанных продуктов.

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

class Website:
"""Общий базовый класс для всех статей/страниц"""
def __init__(self, type, name, url, searchUrl, resultListing,
resultUrl, absoluteUrl, titleTag, bodyTag):
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, productNumber, price):
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

Эта страница товара расширяет базовый класс Website и добавляет атрибуты productNumber и price, которые применяются только к продуктам, а класс Article добавляет атрибуты body и date, которые не применяются к продуктам. Вы можете использовать эти два класса для сканирования, например, веб-сайта магазина, который может содержать блог-посты или пресс-релизы, помимо продуктов.

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

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

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

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

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

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

Scrapy

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

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

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

Установка Scrapy

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

pip install Scrapy

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

Метод установки, который я предпочитаю, — это через менеджер пакетов Anaconda. Anaconda — это продукт, разработанный компанией Continuum, предназначенный для уменьшения трения при поиске и установке популярных пакетов Python для анализа данных.

После установки Anaconda вы можете установить Scrapy, используя эту команду:

conda install -c conda-forge scrapy

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

Инициализация нового паука

После установки фреймворка Scrapy необходимо выполнить небольшую настройку для каждого паука. Паук — это проект 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.

Написание простого скрейпера

Для создания веб-сканера вы добавите новый файл в каталоге spiders по пути wikiSpider/wikiSpider/spiders/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, но пока она просто выводит заголовок страницы.

Вы можете запустить этот паук для статей, перейдя в каталог wikiSpider/wikiSpider и запустив:

$ scrapy runspider article.py

Стандартный вывод Scrapy довольно подробен. Вместе с отладочной информацией он должен вывести строки, подобные следующим:

2018-01-21 23:28:57 [scrapy.core.engine] DEBUG: Crawled (200)

Пауки с правилами

Паук в предыдущем разделе не является настоящим веб-сканером, ограничиваясь только сбором списка URL-адресов, предоставленных ему. Он не имеет способности самостоятельно искать новые страницы. Чтобы превратить его в полноценный веб-сканер, вам нужно использовать класс CrawlSpider, предоставленный Scrapy.

Этот класс можно найти в файле 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. Это указывает пауку, откуда начинать сканирование и следовать ли или игнорировать ссылку на основе домена.

Также предоставляется список правил. Они предоставляют дополнительные инструкции о том, какие ссылки следует следовать или игнорировать (в данном случае вы разрешаете все 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 и LinkExtractor:

rules = [Rule(LinkExtractor(allow=r'.*'), callback='parse_items', follow=True)]

Эта строка предоставляет список объектов Scrapy Rule, которые определяют правила, через которые проходят все найденные ссылки. Когда применяются несколько правил, каждая ссылка проверяется по правилам последовательно. Первое совпадающее правило используется для определения того, как обрабатывать ссылку. Если ссылка не соответствует ни одному правилу, она игнорируется.

Правилу можно предоставить шесть аргументов:

  • link_extractor: Обязательный аргумент, объект LinkExtractor.
  • callback: Функция, которая должна использоваться для анализа содержимого страницы.
  • cb_kwargs: Словарь аргументов, которые должны быть переданы в функцию обратного вызова. Этот словарь форматируется как {имя_аргумента1: значение_аргумента1, имя_аргумента2: значение_аргумента2} и может быть удобным инструментом для повторного использования одних и тех же функций разбора для несколько различных задач.
  • follow: Указывает, нужно ли включить ссылки, найденные на этой странице, в будущий обход. Если функция обратного вызова не предоставлена, это по умолчанию устанавливается в True (ведь если вы ничего не делаете с этой страницей, имеет смысл, что вы хотели бы использовать её для продолжения обхода сайта). Если функция обратного вызова предоставлена, это по умолчанию устанавливается в False.

LinkExtractor — это простой класс, предназначенный исключительно для распознавания и возврата ссылок на странице HTML-контента на основе предоставленных ему правил. У него есть несколько аргументов, которые могут использоваться для принятия или отклонения ссылки на основе селекторов CSS и XPath, тегов (вы можете искать ссылки не только в якорных тегах!), доменов и т. д. Класс LinkExtractor даже может быть расширен, и могут быть созданы пользовательские аргументы. Смотрите документацию Scrapy по извлекателям ссылок для получения дополнительной информации.

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

  • allow Разрешить все ссылки, которые соответствуют предоставленному регулярному выражению.
  • deny Запретить все ссылки, которые соответствуют предоставленному регулярному выражению.

Используя два отдельных класса Rule и LinkExtractor с одной функцией анализа, вы можете создать паука, который обходит Википедию, идентифицируя все статьи и помечая страницы не являющиеся статьями (articlesMoreRules.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))

Напомним, что правила применяются к каждой ссылке в том порядке, в котором они представлены в списке. Все страницы статей (страницы, которые начинаются с /wiki/ и не содержат двоеточия) сначала передаются в функцию parse_items с параметром по умолчанию is_article=True. Затем все остальные ссылки, не относящиеся к статьям, передаются в функцию parse_items с аргументом is_article=False.

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

Создание объектов

До этого вы рассмотрели множество способов нахождения, разбора и обхода веб-сайтов с помощью Scrapy, но 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. Если ваши элементы большие, или вы начинаете перемещать больше функциональности разбора в ваши объекты элементов, вы также можете захотеть извлечь каждый элемент в свой собственный файл. Однако, пока элементы маленькие, я предпочитаю хранить их в одном файле.

В файле articleSpider.py отметьте изменения, внесенные в класс ArticleSpider, чтобы создать новый элемент 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', '.', ...]}

Сохранение данных в Scrapy

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 и записывает вывод в указанном формате в предоставленный файл. Этот файл будет создан, если он еще не существует.

Вы могли заметить, что в пауке articles, созданном в предыдущих примерах, переменная text представляет собой список строк, а не одну строку. Каждая строка в этом списке представляет собой текст внутри одного HTML элемента, в то время как содержимое внутри <div id=»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 формат сохраняет списки как списки. Конечно, вы можете использовать объекты элементов и записывать их в файл или базу данных так, как вам удобно, просто добавив соответствующий код в функцию разбора веб-сканера.

Пайплайн элементов

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

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

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

Чтобы создать пайплайн элементов, перейдите к файлу settings.py, который был создан в начале главы. Вы должны увидеть следующие закомментированные строки:Pipelines Scrapy

Раскомментируйте последние три строки и замените их на следующее:

ITEM_PIPELINES = {
'wikiSpider.pipelines.WikispiderPipeline': 300,
}

Это предоставляет класс Python wikiSpider.pipelines.WikispiderPipeline, который будет использоваться для обработки данных, а также целое число, представляющее порядок выполнения пайплайна, если есть несколько классов обработки. Хотя здесь можно использовать любое целое число, обычно используются числа от 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

Этот файл сохраняется как articlePipelines.py в репозитории GitHub.

Конечно, теперь вам нужно связать файл settings.py и обновленного паука вместе, добавив пайплайн. Когда проект Scrapy был инициализирован в первый раз, был создан файл settings.py в wikiSpider/wikiSpider/settings.py:

class WikispiderPipeline(object):
    def process_item(self, item, spider):
        return item

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

Следующий код следует использовать для замены заглушечного кода в wikiSpider/wikiSpider/settings.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 использует этот метод для асинхронной передачи элементов, собранных пауком. Обработанный объект Article, возвращаемый здесь, будет зарегистрирован или напечатан Scrapy, если, например, вы выводите элементы в JSON или CSV, как это было сделано в предыдущем разделе.

Теперь у вас есть два варианта, когда дело доходит до решения, где выполнять обработку данных: метод parse_items в пауке или метод process_item в пайплайне.

В файле settings.py можно объявить несколько пайплайнов с разными задачами. Однако Scrapy передает все элементы, независимо от их типа, в каждый пайплайн по порядку. Обработка элементов с учетом их типа может быть лучше реализована в пауке, до того как данные попадут в пайплайн. Однако, если эта обработка занимает много времени, стоит рассмотреть возможность перемещения ее в пайплайн (где она может быть обработана асинхронно) и добавления проверки на тип элемента:

def process_item(self, item, spider):
    if isinstance(item, Article):
        # Article-specific processing here

Какую обработку выполнять и где ее выполнять — это важное соображение при написании проектов на Scrapy, особенно больших.

Журналирование (логи) в Scrapy

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

LOG_LEVEL = 'ERROR'

Scrapy использует стандартную иерархию уровней журналирования, следующим образом:

  • CRITICAL
  • ERROR
  • WARNING
  • DEBUG
  • INFO

Если установлено журналирование на ERROR , будут отображаться только CRITICAL и ERROR журналы. Если журналирование установлено на INFO , будут отображаться все журналы, и так далее.

Помимо управления журналированием через файл settings.py, вы можете контролировать, куда направляются журналы из командной строки. Чтобы выводить журналы в отдельный файл журнала вместо терминала, определите файл журнала при запуске из командной строки:

$ scrapy crawl articles -s LOG_FILE=wiki.log

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

Дальнейшее изучение Scrapy

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

Хотя эта глава едва касается возможностей Scrapy, я призываю вас ознакомиться с документацией по Scrapy, а также с книгой «Learning Scrapy» Димитриоса Кузиса-Лукаса (издательство O’Reilly), которая предлагает всестороннее рассмотрение этой библиотеки.

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

Хранение данных

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

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

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

Медиафайлы

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

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

Вот недостатки:

  • Встраивание этих URL-адресов на вашем собственном веб-сайте или в приложении известно как хотлинкинг, и это быстрый способ попасть в неприятности в интернете.
  • Вы не хотите использовать циклы сервера кого-то еще для хостинга медиафайлов для своих собственных приложений.
  • Файл, размещенный по определенному URL, может измениться. Это может привести к неловким последствиям, если, например, вы встраиваете хотлинкнутое изображение на публичный блог. Если вы храните URL с намерением сохранить файл позже для дальнейших исследований, он может в конечном итоге исчезнуть или быть изменен на что-то совершенно неважное в последующие даты.
  • Настоящие веб-браузеры не просто запрашивают HTML-страницу и идут дальше. Они также загружают все ресурсы, необходимые для страницы.
  • Загрузка файлов может помочь сделать ваш скрейпер похожим на то, что его использовал человек, что может быть преимуществом.

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

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

from urllib.request import urlretrieve
from urllib.request import urlopen
from bs4 import BeautifulSoup

html = urlopen('http://www.pythonscraping.com')
bs = BeautifulSoup(html, 'html.parser')
imageLocation = bs.find('a', {'id': 'logo'}).find('img')['src']
urlretrieve(imageLocation, 'logo.jpg')

Скрипт загружает логотип с http://pythonscraping.com и сохраняет его как logo.jpg в том же каталоге, из которого запускается скрипт.

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

import os
from urllib.request import urlretrieve
from urllib.request import urlopen
from bs4 import BeautifulSoup

downloadDirectory = 'downloaded'
baseUrl = 'http://pythonscraping.com'

def getAbsoluteURL(baseUrl, source):
    if source.startswith('http://www.'):
        url = 'http://{}'.format(source[11:])
    elif source.startswith('http://'):
        url = source
    elif source.startswith('www.'):
        url = source[4:]
        url = 'http://{}'.format(source)
    else:
        url = '{}/{}'.format(baseUrl, source)
    if baseUrl not in url:
        return None
    return url

def getDownloadPath(baseUrl, absoluteUrl, downloadDirectory):
    path = absoluteUrl.replace('www.', '')
    path = path.replace(baseUrl, '')
    path = downloadDirectory+path
    directory = os.path.dirname(path)
    if not os.path.exists(directory):
        os.makedirs(directory)
    return path

html = urlopen('http://www.pythonscraping.com')
bs = BeautifulSoup(html, 'html.parser')
downloadList = bs.findAll(src=True)

for download in downloadList:
    fileUrl = getAbsoluteURL(baseUrl, download['src'])
    if fileUrl is not None:
        print(fileUrl)
        urlretrieve(fileUrl, getDownloadPath(baseUrl, fileUrl, downloadDirectory))

Вы знаете все те предупреждения о загрузке неизвестных файлов из Интернета?

Этот скрипт загружает всё, что встречает, на жесткий диск вашего компьютера. Это включает в себя случайные bash-скрипты, .exe файлы и другие потенциальные вредоносные программы. Думаете, что вы в безопасности, потому что никогда не будете запускать что-то из папки загрузок?

Особенно если вы запускаете эту программу с правами администратора, вы просите неприятностей. Что произойдет, если вы встретите файл на веб-сайте, который отправит себя в ../../../../usr/bin/python?

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

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

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

Хранение данных в формате CSV

CSV или значения, разделенные запятыми, является одним из самых популярных форматов файлов для хранения данных таблиц. Он поддерживается Microsoft Excel и многими другими приложениями из-за своей простоты. Вот пример абсолютно допустимого файла CSV:

фрукт,цена
яблоко,1.00
банан,0.30
груша,1.25

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

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

Изменение файла CSV или даже создание его полностью с нуля крайне просто с помощью библиотеки csv Python:

import csv

csvFile = open('test.csv', 'w+')

try:
    writer = csv.writer(csvFile)
    writer.writerow(('число', 'число плюс 2', 'число умножить на 2'))
    for i in range(10):
        writer.writerow((i, i+2, i*2))
finally:
    csvFile.close()

Предостережение о предосторожности: создание файла в Python довольно надежно. Если test.csv еще не существует, Python автоматически создаст файл (но не каталог). Если он уже существует, Python перезапишет test.csv новыми данными. После выполнения вы должны увидеть файл CSV:

число,число плюс 2,число умножить на 2
0,2,0
1,3,2
2,4,4
...

Одной из распространенных задач веб-скрапинга является извлечение HTML-таблицы и запись ее в файл CSV. На странице Википедии «Сравнение текстовых редакторов» представлена достаточно сложная HTML-таблица, содержащая цветовую кодировку, ссылки, сортировку и другой HTML-мусор, который необходимо отбросить перед записью в CSV. Используя BeautifulSoup и функцию get_text(), это можно сделать менее чем за 20 строк:

import csv
from urllib.request import urlopen
from bs4 import BeautifulSoup

html = urlopen('http://en.wikipedia.org/wiki/Comparison_of_text_editors')
bs = BeautifulSoup(html, 'html.parser')

# Основная таблица сравнения в данный момент является первой таблицей на странице
table = bs.find('table', {'class': 'wikitable'})
rows = table.findAll('tr')

csvFile = open('editors.csv', 'wt+')
writer = csv.writer(csvFile)

try:
    for row in rows:
        csvRow = []
        for cell in row.findAll(['td', 'th']):
            csvRow.append(cell.get_text())
        writer.writerow(csvRow)
finally:
    csvFile.close()

Этот код извлекает HTML-таблицу из статьи Википедии о сравнении текстовых редакторов, парсит ее с помощью BeautifulSoup и записывает данные в файл CSV.

Есть более простой способ получить одну таблицу

Этот скрипт отлично подходит для интеграции в скраперы, если вы столкнулись с множеством HTML-таблиц, которые нужно преобразовать в файлы CSV, или с множеством HTML-таблиц, которые нужно собрать в один файл CSV. Однако, если вам нужно сделать это всего лишь один раз, есть более удобный инструмент: копирование и вставка. Выделение и копирование всего содержимого HTML-таблицы и вставка его в Excel или Google Документы позволит вам получить нужный вам файл CSV без запуска скрипта!

MySQL

MySQL (официально произносится как «май эс-кью-эль», хотя многие говорят «май сиквел») — самая популярная сегодня открытая система управления реляционными базами данных. Довольно необычно для проекта с открытым исходным кодом среди крупных конкурентов, его популярность исторически была наравне с двумя другими крупными закрытыми системами управления базами данных: SQL Server от Microsoft и DBMS от Oracle.

Его популярность не случайна. Для большинства приложений сложно ошибиться с MySQL. Это масштабируемая, надежная и полнофункциональная система управления базами данных, используемая на вершинах интернета: YouTube, Twitter и Facebook, среди многих других.

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

«Реляционная» база данных?

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

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

Установка MySQL

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

SELECT * FROM users WHERE firstname = "Ryan"

Если вы используете дистрибутив Linux на основе Debian (или что-то с apt-get), установка MySQL так же проста, как и это:

$ sudo apt-get install mysql-server

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

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

Если вы используете macOS, вам сначала нужно получить установочный пакет. Выберите пакет .dmg и войдите в систему существующей учетной записью Oracle или создайте новую, чтобы загрузить файл. После открытия вы должны будете пройти через довольно простую установочную программу.

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

Если загрузка и запуск установщика кажется вам немного утомительным, а вы используете Mac, вы всегда можете установить менеджер пакетов Homebrew. После установки Homebrew вы также можете установить MySQL, выполнив следующее:

$ brew install mysql

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

После установки MySQL на macOS вы можете запустить сервер MySQL следующим образом:

$ cd /usr/local/mysql
$ sudo ./bin/mysqld_safe

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

Вы должны сможете установить MySQL, используя стандартные настройки, с одним исключением: на странице Тип установки я рекомендую выбрать «Только сервер», чтобы избежать установки большого количества дополнительного программного обеспечения и библиотек Microsoft. Затем вы сможете использовать стандартные настройки установки и следовать инструкциям для запуска вашего сервера MySQL.

Несколько базовых команд

Когда ваш сервер MySQL запущен, у вас есть множество вариантов взаимодействия с базой данных. Множество программных инструментов действуют как посредник, так что вам не нужно иметь дело с командами MySQL (или по крайней мере, иметь с ними дело менее часто). Инструменты, такие как phpMyAdmin и MySQL Workbench, могут облегчить просмотр, сортировку и вставку данных.

Тем не менее, все равно важно знать, как ориентироваться в командной строке. За исключением имен переменных, MySQL нечувствителен к регистру; например, SELECT то же самое, что и sElEcT. Однако по соглашению все ключевые слова MySQL записываются заглавными буквами при написании оператора MySQL. С другой стороны, большинство разработчиков предпочитают называть свои таблицы и базы данных строчными буквами, хотя этот стандарт часто игнорируется. Когда вы впервые входите в MySQL, баз данных для добавления данных еще нет, но вы можете создать одну:

CREATE DATABASE scraping;

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

USE scraping;

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

CREATE TABLE pages;

Это приводит к ошибке: ERROR 1113 (42000): A table must have at least 1 column

В отличие от базы данных, которая может существовать без каких-либо таблиц, таблица в MySQL не может существовать без столбцов. Чтобы определить столбцы в MySQL, их необходимо ввести в виде списка, разделенного запятыми, в скобках, после оператора CREATE TABLE <tablename>:

CREATE TABLE pages (
         id BIGINT(7) NOT NULL AUTO_INCREMENT,
         title VARCHAR(200), 
         content VARCHAR(10000),
         created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 
         PRIMARY KEY(id));

Каждое определение столбца имеет три части:

  • Название (id, title, created и т. д.)
  • Тип данных (BIGINT(7), VARCHAR, TIMESTAMP)
  • При необходимости любые дополнительные атрибуты (NOT NULL AUTO_INCREMENT)
  • В конце списка столбцов необходимо определить ключ таблицы.

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

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

DESCRIBE pages;
+---------+----------------+------+-----+-------------------+----------------+
| Field   | Type           | Null | Key | Default           | Extra          |
+---------+----------------+------+-----+-------------------+----------------+
| id      | bigint(7)      | NO   | PRI | NULL              | auto_increment |
| title   | varchar(200)   | YES  |     | NULL              |                |
| content | varchar(10000) | YES  |     | NULL              |                |
| created | timestamp      | NO   |     | CURRENT_TIMESTAMP |                |
+---------+----------------+------+-----+-------------------+----------------+
4 rows in set (0.01 sec)

Конечно, эта таблица все еще пуста. Вы можете вставить тестовые данные в таблицу pages, используя следующую команду:

INSERT INTO pages (title, content) VALUES ("Заголовок тестовой страницы", "Это некоторое содержимое тестовой страницы. Оно может содержать до 10 000 символов.");

Обратите внимание, что хотя таблица имеет четыре столбца (id, title, content, created), вам нужно определить только два из них (title и content), чтобы вставить строку. Это потому, что столбец id автоматически инкрементируется (MySQL автоматически добавляет 1 при каждой новой вставке строки), и в общем случае он может позаботиться о себе. Кроме того, столбец timestamp устанавливается на текущее время по умолчанию.

Конечно, вы можете изменить эти значения по умолчанию:

INSERT INTO pages (id, title, content, created) VALUES (3, "Заголовок тестовой страницы", "Это некоторое содержимое тестовой страницы. Оно может содержать до 10 000 символов.", "2014-09-21 10:25:32");

Пока целое число, которое вы предоставляете для столбца id, не существует уже в базе данных, это переопределение будет работать отлично. Однако в целом это плохая практика; лучше позвольте MySQL обрабатывать столбцы id и timestamp, если нет убедительной причины поступить иначе.

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

SELECT * FROM pages WHERE id = 2;

Этот оператор говорит MySQL: «Выбери все из pages, где id равен 2.» Знак звездочки (*) действует как подстановочный символ, возвращая все строки, для которых условие (где id равно 2) верно. Он возвращает вторую строку в таблице или пустой результат, если нет строки с id равным 2. Например, следующий оператор, нечувствительный к регистру, возвращает все строки, где поле title содержит «test» (символ % действует как подстановочный символ в строках MySQL):

SELECT * FROM pages WHERE title LIKE "%test%";

Но что, если у вас есть таблица с множеством столбцов, и вам нужны только определенные данные? Вместо выбора всего можно сделать что-то вроде этого:

SELECT id, title FROM pages WHERE content LIKE "%page content%";

Этот запрос возвращает только id и title, где содержание содержит фразу «page content».

Операторы DELETE имеют ту же синтаксическую структуру, что и операторы SELECT:

DELETE FROM pages WHERE id = 1;

Поэтому, особенно при работе с важными базами данных, которые нельзя легко восстановить, рекомендуется сначала написать оператор SELECT вместо DELETE (в этом случае SELECT * FROM pages WHERE id = 1), проверить, что возвращаются только те строки, которые вы хотите удалить, а затем заменить SELECT * на DELETE. Многие программисты имеют ужасные истории о неправильном написании условий в операторе DELETE или, что еще хуже, его полном отсутствии, когда им было срочно, и они уничтожали данные клиентов. Не допустите, чтобы это случилось с вами!

Аналогичные меры предосторожности должны быть предприняты с операторами UPDATE:

UPDATE pages SET title="Новый заголовок", content="Новое содержимое" WHERE id=2;

В рамках этой статьи, вы будете работать только с простыми операторами MySQL, осуществляя базовый выбор, вставку и обновление. Если вам интересно изучение других команд и техник с этим мощным инструментом базы данных, я рекомендую книгу Пола Дюбуа «Кулинарная книга MySQL» (O’Reilly).

Понравилась статья? Поделиться с друзьями:
Школа Виктора Комлева
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!:

Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.