Написание Веб-пауков (краулеров)

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

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

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

Содержание

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

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

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

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

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

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

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

Для поиска русской версии статей Википедии можно использовать сайт ru.wikipedia.org.

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

Шаг 1: Извлечение всех ссылок со страницы

Начнем с простого скрипта, который получает HTML-код страницы и извлекает все ссылки.

from urllib.request import urlopen
from bs4 import BeautifulSoup
from urllib.parse import quote

# Кодируем URL для работы с кириллическими символами
url = 'https://ru.wikipedia.org/wiki/Куценко,_Гоша'
encoded_url = quote(url, safe=':/')

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

for link in bs.find_all('a'):
    if 'href' in link.attrs:
        print(link.attrs['href'])

Этот скрипт выведет все ссылки, включая ссылки на служебные страницы Википедии, такие как «Политика конфиденциальности» и «Связаться с нами».

Шаг 2: Фильтрация ссылок

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

  • Ссылки находятся в элементе div с идентификатором bodyContent.
  • URL не содержат двоеточий.
  • URL начинаются с /wiki/.

Используем эти правила для фильтрации ссылок с помощью регулярного выражения ^(/wiki/)((?!:).)*$.

from urllib.request import urlopen
from bs4 import BeautifulSoup
from urllib.parse import quote
import re

# Кодируем URL для работы с кириллическими символами
url = 'https://ru.wikipedia.org/wiki/Куценко,_Гоша'
encoded_url = quote(url, safe=':/')

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

for link in bs.find('div', {'id': 'bodyContent'}).find_all(
        'a', href=re.compile('^(/wiki/)((?!:).)*$')):
    print(link.attrs['href'])

Этот скрипт выведет только те URL, которые ведут на статьи Википедии со страницы Гоши Куценко.

Пример вывода

Запустив скрипт, вы получите список всех ссылок на статьи, на которые ссылается страница Гоши Куценко.

/wiki/%D0%9A%D0%BE%D0%BC%D1%81%D0%BE%D0%BC%D0%BE%D0%BB%D1%8C%D1%81%D0%BA%D0%B0%D1%8F_%D0%BF%D1%80%D0%B0%D0%B2%D0%B4%D0%B0_%D0%B2_%D0%A3%D0%BA%D1%80%D0%B0%D0%B8%D0%BD%D0%B5
/wiki/%D0%92%D0%BB%D0%B0%D0%B4%D0%B8%D0%BC%D0%B8%D1%80_%D0%92%D0%B0%D1%80%D1%84%D0%BE%D0%BB%D0%BE%D0%BC%D0%B5%D0%B5%D0%B2
/wiki/%D0%AD%D1%85%D0%BE_%D0%9C%D0%BE%D1%81%D0%BA%D0%B2%D1%8B
/wiki/Wayback_Machine
/wiki/Wayback_Machine
/wiki/Wayback_Machine
/wiki/Wayback_Machine
/wiki/%D0%98%D0%B7%D0%B2%D0%B5%D1%81%D1%82%D0%B8%D1%8F
/wiki/%D0%A2%D1%80%D1%83%D0%B4_(%D0%B3%D0%B0%D0%B7%D0%B5%D1%82%D0%B0)
/wiki/%D0%9D%D0%B5%D0%B7%D0%B0%D0%B2%D0%B8%D1%81%D0%B8%D0%BC%D0%B0%D1%8F_%D0%B3%D0%B0%D0%B7%D0%B5%D1%82%D0%B0
/wiki/%D0%A2%D0%B5%D0%BB%D0%B5%D0%BD%D0%B5%D0%B4%D0%B5%D0%BB%D1%8F
/wiki/%D0%A2%D0%B5%D0%BB%D0%B5%D0%BD%D0%B5%D0%B4%D0%B5%D0%BB%D1%8F
/wiki/%D0%A0%D1%83%D1%81%D1%81%D0%BA%D0%BE%D0%B5_%D1%80%D0%B0%D0%B4%D0%B8%D0%BE
/wiki/%D0%9A%D0%BE%D0%BC%D1%81%D0%BE%D0%BC%D0%BE%D0%BB%D1%8C%D1%81%D0%BA%D0%B0%D1%8F_%D0%BF%D1%80%D0%B0%D0%B2%D0%B4%D0%B0
/wiki/%D0%9C%D0%BE%D1%81%D0%BA%D0%BE%D0%B2%D1%81%D0%BA%D0%B8%D0%B9_%D0%BA%D0%BE%D0%BC%D1%81%D0%BE%D0%BC%D0%BE%D0%BB%D0%B5%D1%86
/wiki/Gemeinsame_Normdatei
/wiki/%D0%9C%D0%B5%D0%B6%D0%B4%D1%83%D0%BD%D0%B0%D1%80%D0%BE%D0%B4%D0%BD%D1%8B%D0%B9_%D0%B8%D0%B4%D0%B5%D0%BD%D1%82%D0%B8%D1%84%D0%B8%D0%BA%D0%B0%D1%82%D0%BE%D1%80_%D1%81%D1%82%D0%B0%D0%BD%D0%B4%D0%B0%D1%80%D1%82%D0%BD%D1%8B%D1%85_%D0%BD%D0%B0%D0%B8%D0%BC%D0%B5%D0%BD%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D0%B9
/wiki/%D0%9A%D0%BE%D0%BD%D1%82%D1%80%D0%BE%D0%BB%D1%8C%D0%BD%D1%8B%D0%B9_%D0%BD%D0%BE%D0%BC%D0%B5%D1%80_%D0%91%D0%B8%D0%B1%D0%BB%D0%B8%D0%BE%D1%82%D0%B5%D0%BA%D0%B8_%D0%9A%D0%BE%D0%BD%D0%B3%D1%80%D0%B5%D1%81%D1%81%D0%B0
/wiki/%D0%A3%D0%BD%D0%B8%D0%B2%D0%B5%D1%80%D1%81%D0%B8%D1%82%D0%B5%D1%82%D1%81%D0%BA%D0%B0%D1%8F_%D1%81%D0%B8%D1%81%D1%82%D0%B5%D0%BC%D0%B0_%D0%B4%D0%BE%D0%BA%D1%83%D0%BC%D0%B5%D0%BD%D1%82%D0%B0%D1%86%D0%B8%D0%B8
/wiki/VIAF
/wiki/WorldCat

Теперь мы можем легко изменить URL на страницу Сергея Бондарчука:

from urllib.request import urlopen
from bs4 import BeautifulSoup
from urllib.parse import quote
import re

# Кодируем URL для работы с кириллическими символами
url = 'https://ru.wikipedia.org/wiki/Бондарчук,_Сергей_Фёдорович'
encoded_url = quote(url, safe=':/')

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

for link in bs.find('div', {'id': 'bodyContent'}).find_all(
        'a', href=re.compile('^(/wiki/)((?!:).)*$')):
    print(link.attrs['href'])

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

  1. Написать функцию getLinks, которая принимает URL статьи Википедии в форме /wiki/<Article_Name> и возвращает список всех связанных URL статей в той же форме.
  2. Написать основную функцию, которая вызывает 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):
    # Кодируем URL для работы с кириллическими символами
    base_url = 'https://ru.wikipedia.org'
    html = urlopen(base_url + articleUrl)
    bs = BeautifulSoup(html, 'html.parser')
    return bs.find('div', {'id':'bodyContent'}).find_all('a', 
        href=re.compile('^(/wiki/)((?!:).)*$'))

def main(startArticle):
    links = getLinks(startArticle)
    while len(links) > 0:
        newArticle = links[random.randint(0, len(links) - 1)].attrs['href']
        print(newArticle)
        links = getLinks(newArticle)

# Запуск основной функции с начальной статьей
main('/wiki/Куценко,_Гоша')

Объяснение кода

  1. Инициализация генератора случайных чисел:
    random.seed(datetime.datetime.now())
    

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

  2. Функция getLinks:
    def getLinks(articleUrl):
        base_url = 'https://ru.wikipedia.org'
        html = urlopen(base_url + articleUrl)
        bs = BeautifulSoup(html, 'html.parser')
        return bs.find('div', {'id':'bodyContent'}).find_all('a', 
            href=re.compile('^(/wiki/)((?!:).)*$'))
    

    Эта функция принимает URL статьи, загружает её HTML-код и использует BeautifulSoup для поиска всех ссылок на другие статьи, соответствующие заданному регулярному выражению.

  3. Основная функция main:
    def main(startArticle):
        links = getLinks(startArticle)
        while len(links) > 0:
            newArticle = links[random.randint(0, len(links) - 1)].attrs['href']
            print(newArticle)
            links = getLinks(newArticle)
    

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

  4. Запуск программы:
    main('/wiki/Куценко,_Гоша')
    

    Запускает основную функцию с начальной статьей о Гоше Куценко.

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

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

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

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

Для любопытных: генератор псевдослучайных чисел Python основан на алгоритме Mersenne Twister. Он производит случайные числа, которые трудно предсказать и которые равномерно распределены, однако требует немного больше ресурсов процессора. Хорошие случайные числа не обходятся дешево!

Обработка исключений

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

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

def getLinks(articleUrl):
    try:
        base_url = 'https://ru.wikipedia.org'
        html = urlopen(base_url + articleUrl)
        bs = BeautifulSoup(html, 'html.parser')
        return bs.find('div', {'id':'bodyContent'}).find_all('a', 
            href=re.compile('^(/wiki/)((?!:).)*$'))
    except Exception as e:
        print(f"Произошла ошибка при попытке получить ссылки: {e}")
        return []

Полный код программы с обработкой исключений

from urllib.request import urlopen
from bs4 import BeautifulSoup
import datetime
import random
import re

# Устанавливаем начальное значение генератора случайных чисел системными часами
random.seed(datetime.datetime.now())

def getLinks(articleUrl):
    try:
        base_url = 'https://ru.wikipedia.org'
        html = urlopen(base_url + articleUrl)
        bs = BeautifulSoup(html, 'html.parser')
        return bs.find('div', {'id':'bodyContent'}).find_all('a', 
            href=re.compile('^(/wiki/)((?!:).)*$'))
    except Exception as e:
        print(f"Произошла ошибка при попытке получить ссылки: {e}")
        return []

def main(startArticle):
    links = getLinks(startArticle)
    while len(links) > 0:
        newArticle = links[random.randint(0, len(links) - 1)].attrs['href']
        print(newArticle)
        links = getLinks(newArticle)

# Запуск основной функции с начальной статьей
main('/wiki/Куценко,_Гоша')

Обход всего сайта

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

Темный и глубокий веб

Вы, вероятно, слышали такие термины, как «глубокий веб», «темный веб» или «скрытый веб», особенно в последние годы в СМИ. Что они означают?

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

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

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

Полный обход сайта

Когда обход всего сайта может быть полезен, а когда вреден? Веб-сканеры, которые обходят весь сайт, полезны для множества задач, включая:

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

Реализация обхода

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

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

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

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

# Набор для хранения уникальных страниц
pages = set()

def getLinks(pageUrl):
    base_url = 'https://ru.wikipedia.org'
    html = urlopen(base_url + 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)

# Начальный вызов с пустым URL, который трактуется как главная страница Википедии
getLinks('')

Объяснение кода

  1. Набор pages для хранения уникальных страниц: Набор используется для хранения ссылок на уже посещенные страницы, чтобы избежать их повторного обхода.
  2. Функция getLinks:
    • Загружает HTML-код страницы с заданным URL.
    • Использует BeautifulSoup для парсинга HTML.
    • Находит все ссылки, начинающиеся с /wiki/, независимо от их местоположения на странице или наличия двоеточий.
    • Если ссылка не находится в наборе pages, добавляет ее в набор и вызывает getLinks рекурсивно для этой новой ссылки.

Сбор данных с сайта

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

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

  1. Заголовки: Все заголовки (на всех страницах, независимо от их статуса как страницы статьи, страницы истории изменений или любой другой страницы) имеют заголовки под тегами h1 → span, и это единственные теги h1 на странице.
  2. Содержимое: Весь текст содержится под тегом div#bodyContent. Однако, если вы хотите получить только первый абзац текста, лучше использовать div#mw-content-text → p (выбирая только первый тег абзаца). Это верно для всех страниц контента, кроме страниц файлов (например, страница файла), которые не имеют разделов текстового контента.
  3. Ссылки для редактирования: Ссылки для редактирования появляются только на страницах статей. Если они есть, они будут находиться в теге li#ca-edit, под li#ca-edit → span → a.

Модификация кода

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

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

# Набор для хранения уникальных страниц
pages = set()

def getLinks(pageUrl):
    base_url = 'https://ru.wikipedia.org'
    html = urlopen(base_url + pageUrl)
    bs = BeautifulSoup(html, 'html.parser')
    try:
        # Получаем заголовок страницы
        print(bs.h1.get_text())
        # Получаем первый абзац текста
        print(bs.find(id='mw-content-text').find_all('p')[0].get_text())
        # Получаем ссылку для редактирования, если она существует
        edit_link = bs.find(id='ca-edit').find('span').find('a')
        if edit_link:
            print(base_url + edit_link.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)

# Начальный вызов с пустым URL, который трактуется как главная страница Википедии
getLinks('')

Объяснение кода

  1. Множество pages для хранения уникальных страниц: Используется для хранения ссылок на уже посещенные страницы, чтобы избежать их повторного обхода.
  2. Функция getLinks:
    • Загружает HTML-код страницы с заданным URL.
    • Использует BeautifulSoup для парсинга HTML.
    • Пытается получить и вывести заголовок страницы, первый абзац текста и ссылку для редактирования (если она есть).
    • Если какой-либо элемент отсутствует, ловит исключение AttributeError и выводит сообщение о пропущенном элементе.
    • Находит все внутренние ссылки, начинающиеся с /wiki/, и рекурсивно вызывает getLinks для каждой новой ссылки.

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

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

  1. Серверные перенаправления:
    • Эти перенаправления происходят до загрузки страницы, когда URL меняется на стороне сервера. Вам не нужно беспокоиться об этом, если вы используете библиотеку urllib в Python 3.x, так как она автоматически обрабатывает перенаправления.
  2. Клиентские перенаправления:
    • Эти перенаправления выполняются после загрузки страницы, часто с сообщением вроде «Вы будете перенаправлены через 10 секунд», когда страница сначала загружается, а затем перенаправляется на новый URL.

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

import requests

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

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

Для получения дополнительной информации о клиентских перенаправлениях, которые выполняются с помощью JavaScript или HTML, см. статью Скрейпинг JavaScript.

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

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

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

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

Впереди неизведанные территории

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

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

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

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

Гибкий набор функций на Python для веб-скрапинга

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

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

from urllib.parse import urlparse
from bs4 import BeautifulSoup

# Получает список всех внутренних ссылок на странице
def getInternalLinks(bs, url):
    netloc = urlparse(url).netloc
    scheme = urlparse(url).scheme
    internalLinks = set()
    
    for link in bs.find_all('a'):
        if not link.attrs.get('href'):
            continue
        parsed = urlparse(link.attrs['href'])
        if parsed.netloc == '':
            l = f'{scheme}://{netloc}/{link.attrs["href"].strip("/")}'
            internalLinks.add(l)
        elif parsed.netloc == netloc:
            internalLinks.add(link.attrs['href'])
    
    return list(internalLinks)

Функция getInternalLinks принимает объект BeautifulSoup и URL страницы. Этот URL используется только для определения сетевого расположения (netloc) и схемы (обычно http или https) внутреннего сайта, так что можно использовать любой внутренний URL для целевого сайта, не обязательно точный URL объекта BeautifulSoup.

Функция создает множество internalLinks, которое используется для отслеживания всех внутренних ссылок на странице. Она проверяет все теги <a> на наличие атрибута href, который либо не содержит netloc (является относительным URL, например, «/careers/»), либо имеет netloc, совпадающий с URL, переданным в функцию.

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

# Получает список всех внешних ссылок на странице
def getExternalLinks(bs, url):
    internal_netloc = urlparse(url).netloc
    externalLinks = set()
    
    for link in bs.find_all('a'):
        if not link.attrs.get('href'):
            continue
        parsed = urlparse(link.attrs['href'])
        if parsed.netloc != '' and parsed.netloc != internal_netloc:
            externalLinks.add(link.attrs['href'])
    
    return list(externalLinks)

Функция getExternalLinks работает аналогично getInternalLinks. Она проверяет все теги <a> на наличие атрибута href и ищет те, у которых netloc не совпадает с netloc URL, переданным в функцию.

Получение случайной внешней ссылки

import random
from urllib.request import urlopen

# Получает случайную внешнюю ссылку, начиная с заданной страницы
def getRandomExternalLink(startingPage):
    bs = BeautifulSoup(urlopen(startingPage), 'html.parser')
    externalLinks = getExternalLinks(bs, startingPage)
    
    if not externalLinks:
        print('Нет внешних ссылок, ищем на сайте')
        internalLinks = getInternalLinks(bs, startingPage)
        return getRandomExternalLink(random.choice(internalLinks))
    else:
        return random.choice(externalLinks)

Функция getRandomExternalLink использует функцию getExternalLinks, чтобы получить список всех внешних ссылок на странице. Если найдена хотя бы одна ссылка, она выбирает случайную ссылку из списка и возвращает её. Если внешних ссылок нет, функция выбирает случайную внутреннюю ссылку и рекурсивно вызывает саму себя, пока не найдёт внешнюю ссылку.

Рекурсивное следование только за внешними ссылками

# Рекурсивно переходит по только внешним ссылкам
def followExternalOnly(startingSite):
    externalLink = getRandomExternalLink(startingSite)
    print(f'Случайная внешняя ссылка: {externalLink}')
    followExternalOnly(externalLink)

Функция followExternalOnly использует getRandomExternalLink, а затем рекурсивно переходит по внешним ссылкам в Интернете. Вы можете вызвать её так:

followExternalOnly('https://www.oreilly.com/')

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

http://igniteshow.com/
http://feeds.feedburner.com/oreilly/news
http://hire.jobvite.com/CompanyJobs/Careers.aspx?c=q319
Home Page

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

Схема алгоритма скрапинга

Не используйте примеры программ в продакшене!

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

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

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

Как можно улучшить код

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

from urllib.request import urlopen
from bs4 import BeautifulSoup

# Список всех внешних и внутренних ссылок, найденных на сайте
allExtLinks = []
allIntLinks = []

# Собирает список всех внешних URL-адресов, найденных на сайте
def getAllExternalLinks(url):
    bs = BeautifulSoup(urlopen(url), 'html.parser')
    internalLinks = getInternalLinks(bs, url)
    externalLinks = getExternalLinks(bs, url)
    
    for link in externalLinks:
        if link not in allExtLinks:
            allExtLinks.append(link)
            print(link)
    
    for link in internalLinks:
        if link not in allIntLinks:
            allIntLinks.append(link)
            getAllExternalLinks(link)
    
    allIntLinks.append('https://oreilly.com')  # Убедитесь, что ссылка на начальную страницу добавлена
    getAllExternalLinks('https://www.oreilly.com/')

 

Блок-схема алгоритма сбора ссылок

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

Подготовка к написанию кода

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

Индивидуальное и групповое обучение «Аналитик данных»

Если вы хотите стать экспертом в аналитике, могу помочь. Запишитесь на мой курс «Аналитик данных» и начните свой путь в мир ИТ уже сегодня!

Контакты
Для получения дополнительной информации и записи на курсы свяжитесь со мной:

Телеграм: https://t.me/Vvkomlev
Email: victor.komlev@mail.ru

Объясняю сложное простыми словами. Даже если вы никогда не работали с ИТ и далеки от программирования, теперь у вас точно все получится! Проверено десятками примеров моих учеников.

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

Практическая направленность. 80%: практики, 20% теории. У меня множество авторских заданий, которые фокусируются на практике. Вы не просто изучаете теорию, а сразу применяете знания в реальных проектах и задачах.

Разнообразие учебных материалов: Теория представлена в виде текстовых уроков с примерами и видео, что делает обучение максимально эффективным и удобным.

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

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

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

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

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

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

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

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

Паттерны Веб-краулеров

1. Одностраничный краулер

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

Пример:

from urllib.request import urlopen
from bs4 import BeautifulSoup

url = 'https://ru.wikipedia.org/wiki/Python'
html = urlopen(url)
bs = BeautifulSoup(html, 'html.parser')

title = bs.h1.get_text()
first_paragraph = bs.find(id='mw-content-text').find_all('p')[0].get_text()

print(f'Заголовок: {title}')
print(f'Первый абзац: {first_paragraph}')

2. Многосайтовый краулер

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

Пример:

def get_title(bs, site):
    if site == 'site1':
        return bs.h1.get_text()
    elif site == 'site2':
        return bs.find('span', {'id': 'title'}).get_text()
    # Добавьте правила для других сайтов

# Использование:
site = 'site1'
html = urlopen('https://example.com')
bs = BeautifulSoup(html, 'html.parser')
title = get_title(bs, site)
print(f'Заголовок: {title}')

3. Краулер для сравнения данных

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

Пример:

def get_price(bs, site):
    if site == 'site1':
        return bs.find('span', {'class': 'price'}).get_text()
    elif site == 'site2':
        return bs.find('div', {'class': 'price'}).get_text()
    # Добавьте правила для других сайтов

prices = []
sites = ['site1', 'site2']
urls = ['https://example.com/product1', 'https://example.com/product2']

for site, url in zip(sites, urls):
    html = urlopen(url)
    bs = BeautifulSoup(html, 'html.parser')
    price = get_price(bs, site)
    prices.append(price)

print(f'Цены: {prices}')

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

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

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

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

  • Артикул товара (SKU)

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

  • Твердый/мягкий переплет
  • Матовая/глянцевая печать
  • Количество отзывов покупателей
  • Ссылка на производителя

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

Как избежать ловушек

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

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

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

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

Важные вопросы часть 1

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

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

Пример подхода

Вот примерный подход к организации данных для краулера:

  1. Определите базовые поля, необходимые для идентификации продукта:
    • Название продукта
    • Производитель
    • Артикул (SKU)
  2. Определите дополнительные поля, которые могут быть полезны, но не обязательны:
    • Цена
    • Отзывы
    • Рейтинг
    • Описание
    • Размеры, цвета, материал и т.д.
  3. Создайте структуру данных с разделением на обязательные и дополнительные поля:
    class Product:
        def __init__(self, title, manufacturer, sku=None, price=None, reviews=None, rating=None, description=None):
            self.title = title
            self.manufacturer = manufacturer
            self.sku = sku
            self.price = price
            self.reviews = reviews
            self.rating = rating
            self.description = description
    
  4. Используйте функции для сбора данных с различных сайтов, обеспечивая гибкость и расширяемость:
    def scrape_site1(url):
        # Код для скрапинга сайта 1
        pass
    
    def scrape_site2(url):
        # Код для скрапинга сайта 2
        pass
    
  5. Организуйте управление и сбор данных:
    sites = {
        'site1': scrape_site1,
        'site2': scrape_site2,
    }
    
    for site, scrape_function in sites.items():
        scrape_function(site)
    

Важные вопросы для хранения и обработки данных. Часть 2

Когда вы решаете, какие данные собирать, важно задать несколько дополнительных вопросов, чтобы определить, как хранить и обрабатывать эти данные в коде:

  1. Данные разреженные или плотные? Будут ли они актуальны и заполняться в каждом листинге, или только в нескольких из набора?
  2. Каков объем данных?
  3. Особенно в случае с большими данными, нужно ли будет регулярно их извлекать при каждом запуске анализа или только время от времени?
  4. Насколько изменчивы эти данные? Придется ли регулярно добавлять новые атрибуты, модифицировать типы данных (например, узоры тканей), или это статичные данные (например, размеры обуви)?

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

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

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

Пример атрибутов

Тип атрибута может выглядеть так:

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

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

Хранение информации о ценах

Для отслеживания цен на каждый продукт вам, вероятно, понадобятся следующие поля:

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

Обработка атрибутов, влияющих на цену

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

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

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

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

Пример с новостными статьями

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

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

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

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

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

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

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

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

Подход к написанию парсеров для различных сайтов

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

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

from bs4 import BeautifulSoup
from urllib.request import urlopen

class Content:
    def __init__(self, url, title, body):
        self.url = url
        self.title = title
        self.body = body

    def print(self):
        print(f'TITLE: {self.title}')
        print(f'URL: {self.url}')
        print(f'BODY: {self.body}')

def scrapeCNN(url):
    bs = BeautifulSoup(urlopen(url), 'html.parser')
    title = bs.find('h1').text
    body = bs.find('div', {'class': 'article__content'}).text
    return Content(url, title, body)

def scrapeBrookings(url):
    bs = BeautifulSoup(urlopen(url), 'html.parser')
    title = bs.find('h1').text
    body = bs.find('div', {'class': 'post-body'}).text
    return Content(url, title, body)

# Пример использования
url = 'https://www.brookings.edu/research/robotic-rulemaking/'
content = scrapeBrookings(url)
content.print()

url = 'https://www.cnn.com/2023/04/03/investing/dogecoin-elon-musk-twitter/index.html'
content = scrapeCNN(url)
content.print()

Этот пример показывает, как можно вручную определить, какие элементы на странице содержат заголовок и основной контент. В функциях scrapeCNN и scrapeBrookings мы используем BeautifulSoup для парсинга HTML и поиска нужных элементов с использованием метода find.

Преимущества и недостатки подхода

Преимущества:

  1. Простота реализации: Этот подход легко реализовать и отладить.
  2. Гибкость: Легко добавлять поддержку новых сайтов, просто написав новую функцию парсера для каждого из них.

Недостатки:

  1. Масштабируемость: По мере добавления поддержки большего числа сайтов, количество парсеров будет увеличиваться, что может привести к сложности в управлении и поддержке кода.
  2. Трудоемкость: Требуется вручную анализировать и писать код для каждого нового сайта.

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

Упрощение и упорядочивание веб-скрейпинга с использованием CSS-селекторов

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

Классы Content и Website

Начнем с определения двух базовых классов: Content и Website.

Класс Content представляет контент, извлеченный с веб-страницы:

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 содержит информацию о структуре сайта:

class Website:
    """
    Содержит информацию о структуре сайта
    """
    def __init__(self, name, url, titleTag, bodyTag):
        self.name = name
        self.url = url
        self.titleTag = titleTag
        self.bodyTag = bodyTag

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

Класс Crawler

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

from bs4 import BeautifulSoup
from urllib.request import urlopen

class Crawler:
    @staticmethod
    def getPage(url):
        try:
            html = urlopen(url)
        except Exception as e:
            print(f"Ошибка при загрузке страницы: {e}")
            return None
        return BeautifulSoup(html, 'html.parser')

    @staticmethod
    def safeGet(bs, selector):
        """
        Утилита для получения строки контента из объекта BeautifulSoup и селектора.
        Возвращает пустую строку, если объект не найден для данного селектора.
        """
        selectedElems = bs.select(selector)
        if selectedElems is not None and len(selectedElems) > 0:
            return '\n'.join([elem.get_text() for elem in selectedElems])
        return ''

    @staticmethod
    def getContent(website, path):
        """
        Извлекает контент с данной страницы URL
        """
        url = website.url + path
        bs = Crawler.getPage(url)
        if bs is not None:
            title = Crawler.safeGet(bs, website.titleTag)
            body = Crawler.safeGet(bs, website.bodyTag)
            return Content(url, title, body)
        return Content(url, '', '')

Пример использования

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

# Данные о сайтах
siteData = [
    ['O\'Reilly', 'https://www.oreilly.com', 'h1', 'div.title-description'],
    ['Reuters', 'https://www.reuters.com', 'h1', 'div.ArticleBodyWrapper'],
    ['Brookings', 'https://www.brookings.edu', 'h1', 'div.post-body'],
    ['CNN', 'https://www.cnn.com', 'h1', 'div.article__content']
]

# Создаем объекты сайтов
websites = []
for name, url, title, body in siteData:
    websites.append(Website(name, url, title, body))

# Используем класс Crawler для извлечения контента
content = Crawler.getContent(websites[0], '/library/view/web-scraping-with/9781491910283')
content.print()

content = Crawler.getContent(websites[1], '/article/us-usa-epa-pruitt-idUSKBN19W2D0')
content.print()

content = Crawler.getContent(websites[2], '/blog/techtank/2016/03/01/idea-to-retire-old-methods-of-policy-education/')
content.print()

content = Crawler.getContent(websites[3], '/2023/04/03/investing/dogecoin-elon-musk-twitter/index.html')
content.print()

Преимущества и недостатки подхода

Преимущества:

  1. Легкость добавления новых сайтов: Новый сайт можно добавить, просто указав его имя, URL и CSS-селекторы для заголовка и тела.
  2. Масштабируемость: Легко управлять большим количеством сайтов, так как информация о каждом сайте хранится в виде строки в массиве или базе данных.

Недостатки:

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

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

Расширение и упорядочивание Веб-скрейпера

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

Классы Content и Website

Начнем с определения классов Content и Website.

Класс Content

Этот класс представляет контент, извлеченный с веб-страницы:

class Content:
    """Общий базовый класс для всех статей/страниц"""
    def __init__(self, topic, url, title, body):
        self.topic = topic
        self.url = url
        self.title = title
        self.body = body

    def print(self):
        """Гибкая функция вывода контролирует вывод данных"""
        print('Новая статья найдена для темы: {}'.format(self.topic))
        print('URL: {}'.format(self.url))
        print('ЗАГОЛОВОК: {}'.format(self.title))
        print('ТЕКСТ:\n{}'.format(self.body))

Класс Website

Этот класс содержит информацию о структуре сайта:

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

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

from bs4 import BeautifulSoup
from urllib.request import urlopen

class Crawler:
    def __init__(self, website):
        self.site = website
        self.found = {}

    @staticmethod
    def getPage(url):
        try:
            html = urlopen(url)
        except Exception as e:
            print(f"Ошибка при загрузке страницы: {e}")
            return None
        return BeautifulSoup(html, 'html.parser')

    @staticmethod
    def safeGet(bs, selector):
        """
        Утилита для получения строки контента из объекта BeautifulSoup и селектора.
        Возвращает пустую строку, если объект не найден для данного селектора.
        """
        selectedElems = bs.select(selector)
        if selectedElems is not None and len(selectedElems) > 0:
            return '\n'.join([elem.get_text() for elem in selectedElems])
        return ''

    def getContent(self, topic, url):
        """
        Извлекает контент с данной страницы URL
        """
        bs = Crawler.getPage(url)
        if bs is not None:
            title = Crawler.safeGet(bs, self.site.titleTag)
            body = Crawler.safeGet(bs, self.site.bodyTag)
            return Content(topic, url, title, body)
        return Content(topic, url, '', '')

    def search(self, topic):
        """
        Ищет заданную тему на сайте и записывает все найденные страницы
        """
        bs = Crawler.getPage(self.site.searchUrl + topic)
        searchResults = bs.select(self.site.resultListing)
        for result in searchResults:
            url = result.select(self.site.resultUrl)[0].attrs['href']
            # Проверка, является ли URL абсолютным или относительным
            url = url if self.site.absoluteUrl else self.site.url + url
            if url not in self.found:
                self.found[url] = self.getContent(topic, url)
                self.found[url].print()

Пример использования

Теперь создадим объекты сайтов и используем класс Crawler для скрейпинга:

# Данные о сайтах
siteData = [
    ['Reuters', 'http://reuters.com', 'https://www.reuters.com/search/news?blob=', 
     'div.search-result-indiv', 'h3.search-result-title a', False, 'h1', 'div.ArticleBodyWrapper'],
    ['Brookings', 'http://www.brookings.edu', 'https://www.brookings.edu/search/?s=',
     'div.article-info', 'h4.title a', True, 'h1', 'div.core-block']
]

# Создаем объекты сайтов
sites = []
for name, url, search, rListing, rUrl, absUrl, tt, bt in siteData:
    sites.append(Website(name, url, search, rListing, rUrl, absUrl, tt, bt))

# Создаем объекты Crawler для каждого сайта
crawlers = [Crawler(site) for site in sites]

# Темы для поиска
topics = ['python', 'data%20science']

# Скрейпинг для каждой темы на каждом сайте
for topic in topics:
    for crawler in crawlers:
        crawler.search(topic)

Преимущества и недостатки подхода

Преимущества:

  1. Автоматизация поиска: Автоматическое нахождение ссылок для скрейпинга на основе поиска.
  2. Легкость добавления новых сайтов: Новый сайт можно добавить, указав его имя, URL и CSS-селекторы.
  3. Масштабируемость: Легко управлять большим количеством сайтов и тем для поиска.

Недостатки:

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

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

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

Классы Content и Website

Начнем с определения классов Content и Website.

Класс Content

Этот класс представляет контент, извлеченный с веб-страницы:

class Content:
    """Общий базовый класс для всех статей/страниц"""
    def __init__(self, url, title, body):
        self.url = url
        self.title = title
        self.body = body

    def print(self):
        """Гибкая функция вывода контролирует вывод данных"""
        print(f'URL: {self.url}')
        print(f'ЗАГОЛОВОК: {self.title}')
        print(f'ТЕКСТ:\n{self.body}')

Класс Website

Этот класс содержит информацию о структуре сайта:

import re

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

Класс Crawler

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

from bs4 import BeautifulSoup
from urllib.request import urlopen

class Crawler:
    def __init__(self, site):
        self.site = site
        self.visited = {}

    @staticmethod
    def getPage(url):
        try:
            html = urlopen(url)
        except Exception as e:
            print(f"Ошибка при загрузке страницы: {e}")
            return None
        return BeautifulSoup(html, 'html.parser')

    @staticmethod
    def safeGet(bs, selector):
        """
        Утилита для получения строки контента из объекта BeautifulSoup и селектора.
        Возвращает пустую строку, если объект не найден для данного селектора.
        """
        selectedElems = bs.select(selector)
        if selectedElems is not None and len(selectedElems) > 0:
            return '\n'.join([elem.get_text() for elem in selectedElems])
        return ''

    def getContent(self, url):
        """
        Извлекает контент с данной страницы URL
        """
        bs = Crawler.getPage(url)
        if bs is not None:
            title = Crawler.safeGet(bs, self.site.titleTag)
            body = Crawler.safeGet(bs, self.site.bodyTag)
            return Content(url, title, body)
        return Content(url, '', '')

    def crawl(self):
        """
        Получает страницы с домашней страницы сайта
        """
        bs = Crawler.getPage(self.site.url)
        if bs is None:
            return
        targetPages = bs.findAll('a', href=re.compile(self.site.targetPattern))
        for targetPage in targetPages:
            url = targetPage.attrs['href']
            url = url if self.site.absoluteUrl else f'{self.site.url}{url}'
            if url not in self.visited:
                self.visited[url] = self.getContent(url)
                self.visited[url].print()

Пример использования

Теперь создадим объект сайта и используем класс Crawler для скрейпинга:

# Пример сайта
brookings = Website(
    'Brookings', 'https://brookings.edu', '\/(research|blog)\/', True, 'h1', 'div.post-body')

# Создаем объект Crawler
crawler = Crawler(brookings)

# Запускаем скрейпинг
crawler.crawl()

Обсуждение

Преимущества:

  1. Сбор всех данных: Подходит для сбора всех данных с сайта, а не только из определенных разделов.
  2. Гибкость: Работает с сайтами с менее структурированными страницами.

Недостатки:

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

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

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

  1. По URL: Например, все блоги на сайте могут содержать URL вида http://example.com/blog/title-of-post.
  2. По наличию или отсутствию определенных полей: Например, если страница содержит дату, но не содержит имени автора, можно классифицировать ее как пресс-релиз.
  3. По наличию определенных тегов: Например, элемент <div id="related-products"> может указывать на страницу продукта.

Для отслеживания различных типов страниц можно использовать два подхода:

  1. Добавить атрибут pageType к существующему объекту страницы.
  2. Создать новые классы для каждого типа страницы.

Добавление атрибута pageType

Этот метод полезен, если все страницы имеют схожие типы контента. Например:

class Website:
    def __init__(self, name, url, titleTag, bodyTag, pageType):
        self.name = name
        self.url = url
        self.titleTag = titleTag
        self.bodyTag = bodyTag
        self.pageType = pageType

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

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

class Product(Website):
    """Содержит информацию для скрейпинга страницы продукта"""
    def __init__(self, name, url, titleTag, productNumberTag, priceTag):
        super().__init__(name, url, titleTag, None, 'product')
        self.productNumberTag = productNumberTag
        self.priceTag = priceTag

class Article(Website):
    """Содержит информацию для скрейпинга страницы статьи"""
    def __init__(self, name, url, titleTag, bodyTag, dateTag):
        super().__init__(name, url, titleTag, bodyTag, 'article')
        self.dateTag = dateTag

Класс Crawler для разных типов страниц

Теперь создадим класс Crawler, который будет обрабатывать различные типы страниц:

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

class Crawler:
    def __init__(self, site):
        self.site = site
        self.visited = {}

    @staticmethod
    def getPage(url):
        try:
            html = urlopen(url)
        except Exception as e:
            print(f"Ошибка при загрузке страницы: {e}")
            return None
        return BeautifulSoup(html, 'html.parser')

    @staticmethod
    def safeGet(bs, selector):
        """
        Утилита для получения строки контента из объекта BeautifulSoup и селектора.
        Возвращает пустую строку, если объект не найден для данного селектора.
        """
        selectedElems = bs.select(selector)
        if selectedElems is not None and len(selectedElems) > 0:
            return '\n'.join([elem.get_text() for elem in selectedElems])
        return ''

    def getContent(self, url):
        """
        Извлекает контент с данной страницы URL
        """
        bs = Crawler.getPage(url)
        if bs is not None:
            title = Crawler.safeGet(bs, self.site.titleTag)
            if self.site.pageType == 'product':
                productNumber = Crawler.safeGet(bs, self.site.productNumberTag)
                price = Crawler.safeGet(bs, self.site.priceTag)
                return Content(url, title, f"Product Number: {productNumber}\nPrice: {price}")
            elif self.site.pageType == 'article':
                body = Crawler.safeGet(bs, self.site.bodyTag)
                date = Crawler.safeGet(bs, self.site.dateTag)
                return Content(url, title, f"Date: {date}\n{body}")
        return Content(url, '', '')

    def crawl(self):
        """
        Получает страницы с домашней страницы сайта
        """
        bs = Crawler.getPage(self.site.url)
        if bs is None:
            return
        targetPages = bs.findAll('a', href=re.compile(self.site.targetPattern))
        for targetPage in targetPages:
            url = targetPage.attrs['href']
            url = url if self.site.absoluteUrl else f'{self.site.url}{url}'
            if url not in self.visited:
                self.visited[url] = self.getContent(url)
                self.visited[url].print()

Пример использования

Теперь создадим объекты сайтов и используем класс Crawler для скрейпинга различных типов страниц:

# Пример страницы продукта
productSite = Product(
    'Example Product Site', 'https://example.com', 'h1', 'span#productNumber', 'span.price')

# Пример страницы статьи
articleSite = Article(
    'Example Article Site', 'https://example.com', 'h1', 'div.article-body', 'span.date')

# Создаем объекты Crawler
productCrawler = Crawler(productSite)
articleCrawler = Crawler(articleSite)

# Запускаем скрейпинг
productCrawler.crawl()
articleCrawler.crawl()

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

Итоги

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

  1. Какие данные нужны? Определите, какие данные вам нужны и почему. Это поможет сузить фокус и уменьшить количество обрабатываемой информации.
  2. Как нормализовать данные? При сборе схожих данных из разных источников, цель должна заключаться в их нормализации. Обработка данных с идентичными и сравнимыми полями значительно проще, чем работа с данными, полностью зависящими от исходного формата.
  3. Как минимизировать программную нагрузку? Создавайте скрейперы с учетом возможности добавления новых источников данных в будущем, стремясь минимизировать программную нагрузку при добавлении этих новых источников.
  4. Какие есть общие шаблоны? Даже если на первый взгляд сайт не подходит под вашу модель, возможно, есть более тонкие способы, которыми он всё-таки соответствует. Способность видеть эти скрытые шаблоны может сэкономить время, деньги и избежать множества головных болей.
  5. Как связаны данные? Определите, как связаны различные части данных. Например, ищите информацию с такими свойствами, как «тип», «размер» или «тема», которые охватывают различные источники данных. Как вы будете хранить, извлекать и концептуализировать эти атрибуты?

Архитектура программного обеспечения для веб-скрейпинга

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

  1. Разделение логики и данных: Разделите код на модули, которые отвечают за разные аспекты работы, такие как сбор данных, их обработка и хранение.
  2. Модульность и повторное использование: Стремитесь к созданию модульных компонентов, которые можно использовать повторно. Это значительно упростит добавление новых источников данных.
  3. Управление зависимостями: Используйте средства управления зависимостями для обеспечения воспроизводимости и надежности вашего кода.
  4. Обработка ошибок и исключений: Обеспечьте надежную обработку ошибок и исключений, чтобы ваш скрейпер мог продолжать работу даже при возникновении проблем.
  5. Логирование и мониторинг: Включите механизмы логирования и мониторинга для отслеживания состояния и производительности вашего скрейпера.

Пример структуры веб-скрейпера

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

import requests
from bs4 import BeautifulSoup
import re

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(f'URL: {self.url}')
        print(f'TITLE: {self.title}')
        print(f'BODY:\n{self.body}')

class Crawler:
    def __init__(self, site):
        self.site = site
        self.visited = {}

    @staticmethod
    def getPage(url):
        try:
            response = requests.get(url)
            response.raise_for_status()
            return BeautifulSoup(response.text, 'html.parser')
        except Exception as e:
            print(f"Error fetching page: {e}")
            return None

    @staticmethod
    def safeGet(bs, selector):
        selectedElems = bs.select(selector)
        if selectedElems and len(selectedElems) > 0:
            return '\n'.join([elem.get_text() for elem in selectedElems])
        return ''

    def getContent(self, url):
        bs = Crawler.getPage(url)
        if bs is not None:
            title = Crawler.safeGet(bs, self.site.titleTag)
            body = Crawler.safeGet(bs, self.site.bodyTag)
            return Content(url, title, body)
        return Content(url, '', '')

    def crawl(self):
        bs = Crawler.getPage(self.site.url)
        if bs is None:
            return
        targetPages = bs.findAll('a', href=re.compile(self.site.targetPattern))
        for targetPage in targetPages:
            url = targetPage.attrs['href']
            url = url if self.site.absoluteUrl else f'{self.site.url}{url}'
            if url not in self.visited:
                self.visited[url] = self.getContent(url)
                self.visited[url].print()

# Пример использования
siteData = [
    Website('Example Site', 'https://example.com', '\/(research|blog)\/', True, 'h1', 'div.body'),
    Website('Another Site', 'https://another.com', '\/articles\/', True, 'h1', 'div.content')
]

for site in siteData:
    crawler = Crawler(site)
    crawler.crawl()

 

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

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

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