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

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

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

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

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

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

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

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

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

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

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

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