Обложка: Скрапинг сайта с помощью Python: гайд для новичков

Скрапинг сайта с помощью Python: гайд для новичков

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

Отличие от вызовов API

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

Юридические соображения

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

Установка Beautiful Soup в Python

Beautiful Soup — это Python библиотека для скрапинга данных сайтов через HTML код.
Установите последнюю версию библиотеки.

$ pip install beautifulsoup4

Чтобы делать запросы, установите requests (библиотеку для отправки HTTP запросов):

$ pip install requests

Импортируйте библиотеки в файле Python или Jupiter notebook:

from bs4 import BeautifulSoup
import requests

И несколько стандартных библиотек, которые потребуются для скрапинга на Python:

import re
from re import sub
from decimal import Decimal
import io
from datetime import datetime
import pandas as pd

Введение

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

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

  1. Как получить одну точку данных для одного свойства (например данные из тега price в первом объявлении)?
  2. Как получить все точки данных для одного свойства со всей страницы (например все теги price с одной страницы)?
  3. Как получить все точки данных для одного свойства всех страниц с результатами (например все теги price со всех страниц с результатами)?
  4. Как устранить несоответствие, когда данные могут быть разных типов (например, есть некоторые объявления, в которых в поле цены указана цена по запросу. В конечном итоге у нас будет столбец, состоящий из числовых и строковых значений, что в нашем случае не позволяет провести анализ)?
  5. Как лучше извлечь сложную информацию (Например, предположим, что каждое объявление содержит информацию об общественном транспорте, например “0,5 мили до станции метро XY”)?

Логика получения одной точки данных

Все примеры кода для скрапинга на Python можно найти в Jupiter Notebook файле на GitHub автора.

Запрос кода сайта

Во-первых, мы используем поисковый запрос, который мы сделали в браузере в скрипте Python:

# поиск в определённой зоне
url = 'https://www.website.com/london/page_size=25&q=london&pn=1'

# делаем запрос и получаем html
html_text = requests.get(url).text

# используем парсер lxml
soup = BeautifulSoup(html_text, 'lxml')

Переменная soup содержит полный HTML-код страницы с результатами поиска.

Поиск тегов-свойств

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

HTML классы и атрибут id

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

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

# используем парсер lxml
soup = BeautifulSoup(html_text, 'lxml')

# находим одно объявление
ad = soup.find('div', class_ = 'css-ad-wrapper-123456')

# находим цену
price = ad.find('p', class_ = 'css-aaabbbccc').text

Использование .text в конце метода find() позволяет нам возвращать только обычный текст, как показано в браузере. Без .text он вернет весь исходный код строки HTML, на которую ссылается класс:


Важное примечание: нам всегда нужно указывать элемент, в данном случае это p.

Логика получения всех точек данных с одной страницы

Чтобы получить ценники для всех объявлений, мы применяем метод find.all() вместо find():

ads = ad.find_all('p', class_ = 'css-ad-wrapper-123456')

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

Чтобы получить все ценники, мы используем словарь для сбора данных:

map = {}
id = 0
# получаем все элементы
ads = ad.find_all('p', class_ = 'css-ad-wrapper-123456')

for i in range(len(ads)):

    ad = ads[i]
    id += 1
    map[id] = {}
    
    # находим цену
    price = ad.find('p', class_ = 'css-aaabbbccc').text
    # находим адрес
    address = ad.find('p', class_ = 'css-address-123456').text
    
    map[id]["address"] = address
    map[id]["price"] = price

Важное примечание: использование идентификатора позволяет находить объявления в словаре:

Получение точек данных со всех страниц

Обычно результаты поиска либо разбиваются на страницы, либо бесконечно прокручиваются вниз.

Вариант 1. Веб-сайт с пагинацией

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

ссылки на страницы результатов поиска
Как видно на рисунке выше, окончание URL-адреса относится к номеру страницы результатов.

Важное примечание: номер страницы в URL-адресе обычно становится видимым со второй страницы. Использование базового URL-адреса с дополнительным фрагментом &pn=1 для вызова первой страницы по-прежнему будет работать (в большинстве случаев).

Применение одного цикла for-loop поверх другого позволяет нам перебирать страницы результатов:

url = 'https://www.website.com/london/page_size=25&q=london&pn='

map = {}

id = 0

# максимальное количество страниц
max_pages = 15

for p in range(max_pages):
    
    cur_url = url + str(p + 1)

    print("Скрапинг страницы №: %d" % (p + 1))

    html_text = requests.get(cur_url).text
    soup = BeautifulSoup(html_text, 'lxml')
    
    ads = soup.find_all('div', class_ = 'css-ad-wrapper-123456')

    for i in range(len(ads)):

        ad = ads[i]
        id += 1
        map[id] = {}

        price = ad.find('p', class_ = 'css-aaabbbccc').text
        address = ad.find('p', class_ = 'css-address-123456').text
        map[id]["address"] = address
        map[id]["price"] = price

Определение последней страницы результатов

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

Чтобы решить эту проблему, мы будем проверять, есть ли на странице кнопка с такой ссылкой:

url = 'https://www.website.com/london/page_size=25&q=london&pn='
map = {}
id = 0
# используем очень большое число
max_pages = 9999
for p in range(max_pages):
    
    cur_url = url + str(p + 1)
    print("Скрапинг страницы №: %d" % (p + 1))
    html_text = requests.get(cur_url).text
    soup = BeautifulSoup(html_text, 'lxml')
    ads = soup.find_all('div', class_ = 'css-ad-wrapper-123456')
    
    # ищем ссылку в кнопке
    page_nav = soup.find_all('a', class_ = 'css-button-123456')

    if(len(page_nav) == 0):
        print("Максимальный номер страницы: %d" % (p))
        break
    (...)

Вариант 2. Сайт с бесконечным скроллом

В таком случае HTML скрапер не сработает. Альтернативные методы мы обсудим в конце статьи.

Устранение несогласованности данных

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

Функция для определения аномалий

def is_skipped(price):
    '''
    Определение цен, которые не являются ценами
       (например "Цена по запросу")
    '''
    for i in range(len(price)):
        if(price[i] != '£' and price[i] != ','
           and (not price[i].isdigit())):
              return True
    return False

И применить его при сборе данных:

(...)
for i in range(len(ads)):

        ad = ads[i]
        id += 1
        map[id] = {}

        price = ad.find('p', class_ = 'css-aaabbbccc').text
        # пропускаем объявление без корректной цены
        if(is_skipped(price)): continue
        map[id]["price"] = price

Форматирование данных на лету

Мы могли заметить, что цена хранится в строке вместе с запятыми с символом валюты. Мы можем исправить это ещё на этапе скрапинга:

def to_num(price):
    value = Decimal(sub(r'[^\d.]', '', price))
    return float(value)

Используем эту функцию:

(...)
for i in range(len(ads)):

        ad = ads[i]
        id += 1
        map[id] = {}

        price = ad.find('p', class_ = 'css-aaabbbccc').text
        if(is_dropped(price)): continue
        map[id]["price"] = to_num(price)
        (...)

Получение вложенных данных

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

Отбор информации по правилам

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

map[id]["distance"] = []
map[id]["station"] = []
transport = ad.find_all('div', class_ = 'css-transport-123')
for i in range(len(transport)):
       s = transport[i].text
       x = s.split(' miles ')
       map[id]["distance"].append(float(x[0]))
       map[id]["station"].append(x[1])

Первоначально переменная transport хранит два списка в списке, поскольку есть две строки информации об общественном транспорте (например, “0,3 мили Слоун-сквер”, “0,5 мили Южный Кенсингтон”). Мы перебираем эти списки, используя len транспорта в качестве значений индекса, и разделяем каждую строку на две переменные: расстояние и станцию.

Поиск дополнительных HTML атрибутов для визуальной информации

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

map[id]["distance"] = []
map[id]["station"] = []
map[id]["transport_type"] = []
transport = ad.find_all('div', class_ = 'css-transport-123')
type = ad.find_all('span', class_ = 'css-StyledIcon')
for i in range(len(transport)):
       s = transport[i].text
       x = s.split(' miles ')
       map[id]["distance"].append(float(x[0]))
       map[id]["station"].append(x[1])
       map[id]["transport_type"].append(type[i]['testid'])

Преобразование в датафрейм и экспорт в CSV

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

result = []
cur_row = 0
for idx in range(len(map[1]["distance"])):
    result.append([])
    
    result[cur_row].append(str(map[1]["uuid"]))
    result[cur_row].append(str(map[1]["price"]))
    result[cur_row].append(str(map[1]["address"]))
    result[cur_row].append(str(map[1]["distance"][idx]))
    result[cur_row].append(str(map[1]["station"][idx]))
    result[cur_row].append(str(map[1]["transport_type"][idx]))
                           
    cur_row += 1

Данные без вложенности

Создаём датафрейм

df = pd.DataFrame(result, columns = ["ad_id", "price", "address",  
                     "distance", "station", "transport_type"])

Датафрейм

Мы можем экспортировать датафрейм в CSV:

filename = 'test.csv'
df.to_csv(filename)

Преобразование всех объявлений в датафрейм:

result = []
cur_row = 0
for id in map.keys():
    cur_price = map[id]["price"]
    cur_address = map[id]["address"]
    for idx in range(len(map[id]["distance"])):
        result.append([])
        result[cur_row].append(int(cur_id))
        result[cur_row].append(float(cur_price))
        result[cur_row].append(str(cur_address))
        result[cur_row].append(float(map[id]["distance"][idx]))
        result[cur_row].append(str(map[id]["station"][idx]))
        result[cur_row].append(str(map[id]["transport_type"][idx]))
        cur_row += 1
# преобразование в датафрейм
df = pd.DataFrame(result, columns = ["ad_id", "price","address", "distance", "station", "transport_type"])
# экспорт в csv
filename = 'test.csv'
df.to_csv(filename)

Мы это сделали! Теперь наш скрапер готов к тестированию.

Ограничения HTML скрапинга и его альтернативы

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

Однако HTML скраперы имеют недостатки:

  • Можно получить доступ только к информации в HTML-коде, которая загружается непосредственно при вызове URL-адреса. Веб-сайты, которые требуют JavaScript и Ajax для загрузки контента, не будут работать.
  • HTML-классы или идентификаторы могут изменяться в связи с обновлениями веб-сайта.
  • Может быть легко обнаружен, если запросы кажутся аномальными для веб-сайта (например, очень большое количество запросов в течение короткого промежутка времени).

Альтернативы:

  • Shell скрипты — загружают всю страницу, с помощью регулярных выражений могут обрабатывать html.
  • Screen scraper — изображают реального пользователя, используют браузер (Selenium, PhantomJS).
  • ПО для скрапинга — рассчитаны на стандартные случаи, не требуют написания кода (webscraper.io).
  • Веб сервисы скраперы — не требуют написания кода, хорошо справляются со скрапингом, платные (zyte.com).

Здесь вы найдёте список инструментов и библиотек для скрапинга.

Источник Turn Website Data Into Data Sets: A Beginner’s Guide to Python Web Scraping