Web scraping на R, часть 2. Ускорение процесса с помощью параллельных вычислений и использование пакета Rcrawler

в 6:46, , рубрики: data mining, parallel programming, R, scraping, параллельное программирование

Web scraping на R, часть 2. Ускорение процесса с помощью параллельных вычислений и использование пакета Rcrawler - 1

В прошлой статье я с помощью скрэпинга-парсинга собрал с сайтов IMDB и Кинопоиск оценки фильмов и сравнил их. Репозиторий на Github.

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

Web scraping на R, часть 2. Ускорение процесса с помощью параллельных вычислений и использование пакета Rcrawler - 2

Когда решил использовать scraping_imdb.R для обхода 1000 страниц

Оптимизация кода. Однократное использование функции read_html

В данной статье для проверки работы и скорости кода будут использоваться 100 ссылок на страницы книжного магазина "Лабиринт".

Явное изменение, которое может ускорить процесс — это однократное использование самой "медленной" функции кода — read_html. Напомню, что она "читает" HTML-страницу. В первой версии кода для киносайтов, я запускал read_html каждый раз, когда мне нужно было получить какое-то значение (название фильма, год, жанр, оценку). Сейчас следы этого "позора" стёрты с GitHuba, но это так. Смысла в этом нет никакого, ведь переменная, созданная с использованием read_html содержит информацию о ВСЕЙ странице и для получения из неё разных данных достаточно подавать на вход функции html_nodes эту самую переменную, а не запускать чтение HTML каждый раз. Так можно сэкономить время пропорционально количеству значений, которые Вы хотите получить. С Лабиринта я получаю семь значений, соответственно код, в котором используется только однократное чтение HTML-страницы, будет работать примерно в семь раз быстрее. Неплохо! Но перед тем, как "ускориться" ещё раз, сделаю отступление и расскажу об интересных моментах которые возникают при скрэпинге с сайта Лабиринт.

Особенности скрэпинга страниц на Лабиринте

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

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

Поэтому я не придумал ничего лучше, чем обходить ссылки https://www.labirint.ru/books/**** простым перебором. Метод прямо скажем, не самый лучший (хотя бы потому что большинство "древних" книг никакой информации кроме названия не имеют и поэтому практически бесполезны), поэтому если кто предложит более элегантное решение, я буду рад. Зато я узнал, что под гордым первым номером на сайте Лабиринта идёт книжка "Как сделать самогон". Увы, но купить этот кладезь знаний уже невозможно.

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

  • Страницы, которые существуют
  • Страницы, которые не существуют

Существующие страницы, в свою очередь, можно поделить ещё на две части:

  • Страницы, которые содержат всю необходимую информацию
  • Страницы, которые не содержат всю необходимую информацию

В конечном итоге я получаю таблицу данных с семью столбцами:

  1. ISBN — номер книги по ISBN
  2. PRICE — цена книги
  3. NAME — название книги
  4. AUTHOR — автор книги
  5. PUBLISHER — издательство
  6. YEAR — год издания
  7. PAGE — число страниц

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

Что касается страниц, на которых каких-то данных нет, то с ними не всё так просто. Поиск по странице вернёт только те значения, которые найдёт и длина вывода уменьшится на то количество элементов, которые он не найдёт. Это нарушит всю структуру. Чтобы избежать этого в каждый аргумент была добавлена конструкция if...else, которая оценивает длину вектора, полученного после использования функции html_nodes и если она равна нулю, то возвращает NA, чтобы избежать смещения значений.

    PUBLISHER <- unlist(lapply(list_html, function(n){
      publishing <- if(n != "NA") {
        publishing_html <- html_nodes(n, ".publisher a")
        publishing <- if(length(publishing_html) == 0){
          NA
        } else {
          publishing <- html_text(publishing_html)
        }
      } else {
        NA
      }
    }))

Но как Вы можете заметить здесь целых два if и целых два else. К решению проблемы, описанной выше имеют отношения только "внутренние" if..esle. Наружные решают проблему с несуществующими страницами.

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

В этом нам поможет функция possibly пакета purr. Смысл функций-наречий (помимо possibly это quietly и safely) — это замена печатного вывода побочных эффектов (например ошибок) на значение, устраивающее нас. possibly имеет структуру possibly(.f, otherwise) и при возникновении ошибки в коде она, вместо остановки его выполнения, использует значение по умолчанию (otherwise). В нашем случае это выглядит так:

book_html <- possibly(read_html, "NA")(n)

n — это список адресов страниц сайта, которые мы соскабливаем. На выходе мы получим список, длиной n, в котором элементы с существующих страниц будут в "нормальном" для выполнения функции read_html виде, а элементы с несуществующих страниц будут состоять из символьного вектора "NA". Обратите внимание, что значение по умолчанию должно быть символьным вектором, потому что в дальнейшем мы будем к нему обращаться. Если мы напишем просто NA, как в части кода PUBLISHER это будет невозможно. Для избежания путаницы можно изменить otherwise значение с NA на любое другое.

А теперь вернёмся к коду получения названия издательства. Внешний if...else нужен для тех же целей, что и внутренний, но в отношении несуществующих страниц. Если переменная book_html равна "NA", то каждое из "соскабливаемых" значений также равно NA(здесь уже можно использовать "настоящее" NA, а не символьного самозванца). Так в итоге, мы получаем таблицу следующего вида:

ISBN PRICE NAME AUTHOR PUBLISHER YEAR PAGE
4665305770322 1488 Набор Стринг Арт "Милый щенок" (30*30 см) (DH6021) NA Рыжий Кот 2019 NA
NA NA NA NA NA NA NA
9785171160814 273 Аркадий Аверченко: Весёлые рассказы для детей Автор: Аверченко Аркадий Тимофеевич, Художник: Власова Анна Юльевна Малыш 2019 288

Теперь вернёмся с ускорению процесса скрэпинга.

Параллельное вычисление в R. Сравнение скорости и подводные камни при использовании функции read_html

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

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

Сразу расскажу об ошибке, какую я совершил при использовании параллельных вычислений. Первоначально мой план был такой: Я с помощью параллельных вычислений получаю список из 100 "прочитанных" read_html страниц, а затем уже в обычном режиме просто получаю нужные данные. Сначала всё шло неплохо: я получил список, затратив на это гораздо меньше времени, чем при обычном режиме R. Вот только при попытке какого-то взаимодействия с этим списком, мне выдавалась ошибка:

Error: external pointer is not valid

В итоге я понял в чём проблема, посмотрев примеры в интернете, а уже после, по закону подлости, нашёл объяснение Хенрика Бенгтссона в виньетке к пакету future. Дело в том, что XML-функции пакета xml2 являются неэкспортируемыми объектами (Non-exportable objects
). Эти объекты "привязаны" к данной сессии R и не могут быть переданы другому процессу, что я и пытался сделать. Поэтому функция, запускаемая при параллельных вычислениях, должна содержать "полный цикл" операций: чтение HTML-страницы, получение и очистку нужных данных.

Само создание параллельных вычислений не занимает много времени и строк кода. Первое что нужно — загрузить библиотеки. В репозитории на Github указаны какие пакеты нужны для каких способов. Здесь я покажу параллельные вычисления с помощью функции parLapply пакета parallel. Для этого достаточно запустить doParallel (parallel в таком случае запустится автоматически). Если Вы вдруг не знаете или забыли количество ядер Вашего процессора детектировать сколько их поможет функция detectCores

# detectCores - функция, считающая количество ядер процессора
    number_cl <- detectCores()

Далее создаём параллельно работающие копии R:

    # makePSOCKcluster - функция создающая копии R, которые работают параллельно
    cluster <- makePSOCKcluster(number_cl)
    registerDoParallel(cluster)

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

А дальше процедура почти ничем не отличается от использования обычной функции lapply. В parLapply мы подаём список из адресов, собственную функцию и, единственное дополнение, переменную с созданными нами кластерами.

# parLapply - аналог lapply функции для параллельных вычислений
    big_list <- parLapply(cluster, list_url, scraping_parellel_func)

# Прекращение параллельных вычислений
    stopCluster(cluster)

Вот собственно и всё, теперь осталось сравнить затраченное время.

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

Это будет самый короткий пункт. Параллельное вычисление получилось в 5 раз быстрее обычного:

Скорость скрэпинга без использования параллельных вычислений

пользователь система прошло
13.57 0.40 112.84

Скорость скрэпинга с использованием параллельных вычислений

пользователь система прошло
0.14 0.05 21.12

Что сказать? Параллельные вычисления могут сэкономить кучу Вашего времени, не создавая в создании кода каких-то сложностей. При увеличении количества ядер скорость будет расти практически пропорционально их числу. Вот так путём некоторых изменений мы ускорили код сначала в 7 раз (перестав вычислять read_html на каждом шагу), а затем ещё в 5, использовав параллельные вычисления. Скрипты "пауков" без параллельных вычислений, с помощью пакета parallel и foreach лежат в репозитории на Github.

Небольшой обзор пакета Rcrawler. Сравнение скорости.

В R есть ещё несколько способов соскабливания HTML-страниц, но я остановлюсь на пакете Rcrawler. Его отличительная особенность от остальных средств в языке R — это возможность обхода сайтов. Вы можете задать одноимённой функции Rcrawler адрес сайта и она будет методично, страница за страницей, обходить весь сайт. В Rcrawler имеется множество аргументов настройки поиска (например можно задать поиск по ключевым словам, секторам сайта (полезно, когда сайт состоит из большого числа страниц), глубину поиска, игнорирование параметров URL, которые создают страницы-дубликаты, и многое другое. Также в этой функции уже заложены параллельные вычисления, которые задаются аргументами no_cores (количество задействованных ядер процессора) и no_conn (количество параллельных запросов).

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

# Аргументы функции ContentScraper:
  # CssPatterns - один или несколько CSS шаблонов для извлечения данных.

  # ExcludeCSSPat - один или несколько CSS шаблонов, которые не нужно извлекать.
  # Полезно, когда нужный CSS содержит в себе ещё CSS элементы, которые нам не нужны.

  # ManyPerPattern - если FALSE, то извлекается только первый элемент со страницы,
  # подходящий шаблону. Если TRUE, то все элементы со страницы, подходящие по шаблону.

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

  t_func <- function(n){
    library(Rcrawler)
    t <- ContentScraper(n, CssPatterns = c("#product-title", 
                                           ".authors", 
                                           ".buying-price-val-number",
                                           ".buying-pricenew-val-number",
                                           ".publisher",
                                           ".isbn",
                                           ".pages2"),
                        ExcludeCSSPat = c(".prodtitle-availibility",
                                          ".js-open-block-page_count"), 
                        ManyPerPattern = FALSE,
                        PatternsName = c("title",
                                         "author",
                                         "price1",
                                         "price2",
                                         "publisher",
                                         "isbn",
                                         "page"))
    return(t)
  }

Но при всех положительных качествах у функции ContentScraper есть один очень серьёзный минус — скорость работы.

Скорость скрэпинга функцией ContentScraper пакета Rcrawler без использования параллельных вычислений

пользователь система прошло
47.47 0.29 212.24

Скорость скрэпинга функцией ContentScraper пакета Rcrawler с использованием параллельных вычислений

пользователь система прошло
0.01 0.00 67.97

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

Буду благодарен за любые комментарии, пожелания, претензии
Ссылка на репозиторий Github
Профиль на "Мой круг"

Автор: Gers1972

Источник


* - обязательные к заполнению поля