8  Веб-скрапинг

Файлы html, как и XML, хранят данные в структурированном виде. Извлечь их позволяет пакет rvest. С его помощью мы добудем архив телеграм-канала Antibarbari HSE. Канал публичный, и Telegram дает возможность скачать архив в формате html при помощи кнопки export (эта функция может быть недоступна на MacOS, в этом случае стоит попробовать Telegram Lite).

Эта глава опирается в основом на второе издание книги R for Data Science Хадли Викхема.

8.1 Структура html

Документы html (HyperText Markup Language) имеют ирархическую структуру, состоящую из элементов. В каждом элементе есть открывающий тег (<tag>), опциональные атрибуты (id='first') и закрывающий тег (</tag>). Все, что находится между открывающим и закрывающим тегом, называется содержанием элемента.

Важнейшие теги, о которых стоит знать:

  • <html> (есть всегда), с двумя детьми (дочерними элементами): <head> и <body>;
  • элементы, отвечающие за структуру: <h1> (заголовок), <section>, <p> (параграф), <ol> (упорядоченный список);
  • элементы, отвечающие за оформление: <b> (bold), <i> (italics), <a> (ссылка).

Чтобы увидеть структуру веб-страницы, надо нажать правую кнопку мыши и выбрать View Source (это работает и для тех html, которые хранятся у вас на компьютере).

8.2 Каскадные таблицы стилей

У тегов могут быть именованные атрибуты; важнейшие из них – это id и class, которые в сочетании с CSS контролируют внешний вид страницы.

На заметку

CSS (англ. Cascading Style Sheets «каскадные таблицы стилей») — формальный язык декорирования и описания внешнего вида документа (веб-страницы), написанного с использованием языка разметки (чаще всего HTML или XHTML).

Пример css-правила (такие инфобоксы использованы в предыдущей версии курса):

.infobox {
  padding: 1em 1em 1em 4em;
  background: aliceblue 5px center/3em no-repeat;
  color: black;
}

Проще говоря, это инструкция, что делать с тем или иным элементом. Каждое правило CSS имеет две основные части — селектор и блок объявлений. Селектор, расположенный в левой части правила до знака {, определяет, на какие части документа (возможно, специально обозначенные) распространяется правило. Блок объявлений располагается в правой части правила. Он помещается в фигурные скобки, и, в свою очередь, состоит из одного или более объявлений, разделённых знаком «;».

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

  • p выберет все элементы <p>
  • .title выберет элементы с классом “title”
  • #title выберет все элементы с атрибутом id=‘title’

Важно: если изменится структура страницы, откуда вы скрапили информацию, то и код придется переписывать.

8.3 Чтение html

Чтобы прочесть файл html, используем одноименную функцию.

library(rvest)
antibarbari_files <- list.files("../files/antibarbari_2024-08-18", pattern = "html", full.names = TRUE)

Используем пакет purrr, чтобы прочитать сразу три файла из архива.

library(tidyverse)
antibarbari_archive <- map(antibarbari_files, read_html)

8.4 Парсинг html: отдельные элементы

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

example_html <-  minimal_html("
<div class='message default clearfix' id='message83'>
      <div class='pull_left userpic_wrap'>
       <div class='userpic userpic2' style='width: 42px; height: 42px'>
        <div class='initials' style='line-height: 42px'>
A
        </div>
       </div>
      </div>
      <div class='body'>
       <div class='pull_right date details' title='19.05.2022 11:18:07 UTC+03:00'>
11:18
       </div>
       <div class='from_name'>
Antibarbari HSE 
       </div>
       <div class='text'>
Этот пост открывает серию переложений из «Дайджеста платоновских идиом» Джеймса Ридделла (1823–1866), английского филолога-классика, чей научный путь был связан с Оксфордским университетом. По приглашению Бенджамина Джоветта он должен был подготовить к изданию «Апологию», «Критон», «Федон» и «Пир». Однако из этих четырех текстов вышла лишь «Апология» с предисловием и приложением в виде «Дайджеста» (ссылка) — уже после смерти автора. <br><br>«Дайджест» содержит 326 параграфов, посвященных грамматическим, синтаксическим и риторическим особенностям языка Платона. Знакомство с этим теоретическим материалом позволяет лучше почувствовать уникальный стиль философа и добиться большей точности при переводе. Ссылки на «Дайджест» могут быть уместны и в учебных комментариях к диалогам Платона. Предлагаемая здесь первая часть «Дайджеста» содержит «идиомы имен» и «идиомы артикля» (§§ 1–39).<br><a href='http://antibarbari.ru/2022/05/19/digest_1/'>http://antibarbari.ru/2022/05/19/digest_1/</a>
       </div>
       <div class='signature details'>
Olga Alieva
       </div>
      </div>
     </div>
")

Из всего этого мне может быть интересно id сообщения (\<div class='message default clearfix' id='message83'\>), текст сообщения (\<div class='text'\>), дата публикации (\<div class='pull_right date details' title='19.05.2022 11:18:07 UTC+03:00'\>), а также, если указан, автор сообщения (\<div class='signature details'\>). Извлекаем текст (для этого рекомендуется использовать функцию html_text2()):

example_html |>
  html_element(".text") |> 
  html_text2()
[1] "Этот пост открывает серию переложений из «Дайджеста платоновских идиом» Джеймса Ридделла (1823–1866), английского филолога-классика, чей научный путь был связан с Оксфордским университетом. По приглашению Бенджамина Джоветта он должен был подготовить к изданию «Апологию», «Критон», «Федон» и «Пир». Однако из этих четырех текстов вышла лишь «Апология» с предисловием и приложением в виде «Дайджеста» (ссылка) — уже после смерти автора.\n\n«Дайджест» содержит 326 параграфов, посвященных грамматическим, синтаксическим и риторическим особенностям языка Платона. Знакомство с этим теоретическим материалом позволяет лучше почувствовать уникальный стиль философа и добиться большей точности при переводе. Ссылки на «Дайджест» могут быть уместны и в учебных комментариях к диалогам Платона. Предлагаемая здесь первая часть «Дайджеста» содержит «идиомы имен» и «идиомы артикля» (§§ 1–39).\nhttp://antibarbari.ru/2022/05/19/digest_1/"

В классе signature details есть пробел, достаточно на его месте поставить точку:

example_html |>
  html_element(".signature.details") |> 
  html_text2()
[1] "Olga Alieva"

Осталось добыть дату и message id:

example_html |> 
  html_element(".pull_right.date.details") |> 
  html_attr("title")
[1] "19.05.2022 11:18:07 UTC+03:00"
example_html |>
  html_element("div") |> 
  html_attr("id")
[1] "message83"

Теперь мы можем сохранить все нужные нам данные в таблицу.

tibble(id = example_html |> 
         html_element("div") |> 
         html_attr("id"),
       date = example_html |> 
         html_element(".pull_right.date.details") |> 
         html_attr("title"),
       signature = example_html |>
         html_element(".signature.details") |> 
         html_text2(),
       text = example_html |> 
         html_element(".text") |>
         html_text2()
)

8.5 Парсинг html: вложенные элементы

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

archive_1 <- antibarbari_archive[[1]]

archive_1 |>
  html_elements("div.message.default") |> 
  head()
{xml_nodeset (6)}
[1] <div class="message default clearfix" id="message3">\n\n      <div class= ...
[2] <div class="message default clearfix" id="message5">\n\n      <div class= ...
[3] <div class="message default clearfix" id="message6">\n\n      <div class= ...
[4] <div class="message default clearfix" id="message7">\n\n      <div class= ...
[5] <div class="message default clearfix" id="message8">\n\n      <div class= ...
[6] <div class="message default clearfix" id="message9">\n\n      <div class= ...

Уже из этого набора узлов можем доставать все остальное.

archive_1_tbl <- tibble(id = archive_1 |> 
         html_elements("div.message.default") |> 
         html_attr("id"),
       date = archive_1 |> 
         html_elements("div.message.default") |> 
         html_element(".pull_right.date.details") |> 
         html_attr("title"),
       signature = archive_1 |>
         html_elements("div.message.default") |> 
         html_element(".signature.details") |> 
         html_text2(),
       text = archive_1 |> 
         html_elements("div.message.default") |> 
         html_element(".text") |>
         html_text2()
)

archive_1_tbl

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

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

scrape_antibarbari <- function(html_file){
  messages_tbl <- tibble(
    id = html_file |>
      html_elements("div.message.default") |>
      html_attr("id"),
    date = html_file |>
      html_elements("div.message.default") |>
      html_element(".pull_right.date.details") |>
      html_attr("title"),
    signature = html_file |>
      html_elements("div.message.default") |>
      html_element(".signature.details") |>
      html_text2(),
    text = html_file |>
      html_elements("div.message.default") |>
      html_element(".text") |>
      html_text2()
  )
  messages_tbl
}


messages_tbl <- map_df(antibarbari_archive, scrape_antibarbari)

Вот что у нас получилось.

messages_tbl

8.6 Разведывательный анализ

Создатели канала не сразу разрешили подписывать посты, поэтому для первых нескольких десятков подписи не будет. Кроме того, в некоторых постах только фото, для них в столбце text – NA, их можно сразу отсеять.

messages_tbl <- messages_tbl |>
  filter(!is.na(text))

messages_tbl

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

Задание

Из курса Getting and Cleaning Data в swirl будет полезно пройти урок Dates and Times with lubridate.

messages_tbl2 <- messages_tbl |> 
  separate(date, into = c("date", "time", NA), sep = " ") |> 
  mutate(date = dmy(date), 
         time = hms(time)) |> 
  mutate(year = year(date), 
        month = month(date, label = TRUE),
        wday = wday(date, label = TRUE),
        hour = hour(time),
        length = str_count(text, " ") + 1) |> 
  mutate(wday = factor(wday, levels = c("Sun", "Sat", "Fri", "Thu", "Wed", "Tue", "Mon")))


messages_tbl2
summary1 <- messages_tbl2 |> 
  group_by(year, month) |> 
  summarise(n = n()) 

summary1
summary2 <- messages_tbl2 |> 
  group_by(year, hour) |> 
  summarise(n = n()) |> 
  mutate(hour = case_when(hour == 0 ~ 24,
                          .default = hour))

summary2
summary3 <- messages_tbl2 |> 
   group_by(wday) |> 
   summarise(n = n())

summary3
library(gridExtra)
library(grid)
library(paletteer)
cols <- paletteer_d("khroma::okabeitoblack")

p1 <- summary1 |> 
  ggplot(aes(month, n, color = as.factor(year), group = year)) +
  geom_line(show.legend = FALSE, linewidth = 1.2, alpha = 0.8) +
  labs(title = "Число постов в месяц") +
  theme(legend.title = element_blank(), 
        legend.position = c(0.8, 0.3),
        title = element_text(face="italic")) +
  labs(x = NULL, y = NULL) +
  scale_color_manual(values = cols[1:3])


p2 <- summary2 |> 
  ggplot(aes(hour, n, color = as.factor(year), group = year)) + 
  geom_line(linewidth = 1.2, alpha = 0.8) +
  scale_x_continuous(breaks = seq(1,24,1)) +
  labs(x = NULL, y = NULL, title = "Время публикации поста") + 
  theme(legend.title = element_blank(), 
        legend.position = "left",
        axis.text.y = element_blank(),
        axis.ticks.y = element_blank(),
        title = element_text(face="italic")
        ) +
  coord_polar(start = 0) +
  scale_color_manual(values = cols[1:3])


p3 <- summary3 |> 
  ggplot(aes(wday, n, fill = wday)) + 
  geom_bar(stat = "identity", 
           show.legend = FALSE) + 
  coord_flip() + 
  labs(x = NULL, y = NULL, title  = "Публикации по дням недели") +
  scale_fill_manual(values = cols) +
  theme(title = element_text(face="italic"))


p4 <- messages_tbl2 |> 
  ggplot(aes(as.factor(year), length, fill = as.factor(year))) +
  geom_boxplot(show.legend = FALSE) +
  labs(title = "Длина поста по годам") + 
  labs(x = NULL, y = NULL) + 
  scale_fill_manual(values = cols[1:3]) + 
  theme(title = element_text(face="italic"))


grid.arrange(p1, p2, p3, p4, nrow = 2,
             top =  textGrob("Телеграм-канал Antibarbari HSE",
                    gp=gpar(fontsize=16)),
             bottom = textGrob("@Rantiquity",
                    gp = gpar(fontface = 3, fontsize = 9), hjust = 1, x = 1)) 

8.7 Html таблицы

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

Таблицы HTML строятся из четырех основных элементов: <table>, <tr> (строка таблицы), <th> (заголовок таблицы) и <td> (данные таблицы). Мы достанем программу курса “Количественные методы в гуманитарных науках: критическое введение” (2023/2024).

html <- read_html("http://criticaldh.ru/program/")
my_table <- html |>  
  html_table() |> 
  pluck(1)

my_table
Задание

С сайта Новой философской энциклопедии извлеките список слов на букву П. Используйте map_df() для объединения таблиц.

Вопрос

Сколько всего слов на букву П в НФЭ?

8.8 Selector Gadget

Многие тексты доступны на сайте <wikisource.org>. Попробуем извлечь латинский текст “Записок о Галльской войне” Цезаря: он пригодится нам в следующем уроке.

url <- "https://la.wikisource.org/wiki/Commentarii_de_bello_Gallico"
html = read_html(url)

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

toc <- html |> 
  html_elements("td , #toc a")

toc
{xml_nodeset (11)}
 [1] <td class="fr-text" style="vertical-align: middle;">Accuracy</td>\n
 [2] <td class="fr-value40" style="vertical-align: middle;">Spot checked</td>
 [3] <td align="center" style="background: #efefef">\n<a href="/wiki/Commenta ...
 [4] <a href="/wiki/Commentarii_de_bello_Gallico/Liber_I" title="Commentarii  ...
 [5] <a href="/wiki/Commentarii_de_bello_Gallico/Liber_II" title="Commentarii ...
 [6] <a href="/wiki/Commentarii_de_bello_Gallico/Liber_III" title="Commentari ...
 [7] <a href="/wiki/Commentarii_de_bello_Gallico/Liber_IV" title="Commentarii ...
 [8] <a href="/wiki/Commentarii_de_bello_Gallico/Liber_V" title="Commentarii  ...
 [9] <a href="/wiki/Commentarii_de_bello_Gallico/Liber_VI" title="Commentarii ...
[10] <a href="/wiki/Commentarii_de_bello_Gallico/Liber_VII" title="Commentari ...
[11] <a href="/wiki/Commentarii_de_bello_Gallico/Liber_VIII" title="Commentar ...

Извлекаем путь и имя файла для web-страниц.

libri <- tibble(
  title = toc |>
    html_attr("title"),
  href = toc |> 
    html_attr("href")
) |> 
  filter(!is.na(title))

libri

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

libri <- libri |> 
  mutate(link = paste0("https://la.wikisource.org", href)) |> 
  select(-href)

libri

Дальше необходимо достать текст для каждой книги. Потренируемся на одной. Снова привлекаем Selector Gadget для составления правила.

urls <- libri |> 
  pull(link)

text <- read_html(urls[1]) |> 
  html_elements(".mw-heading3+ p") |> 
  html_text2() 

text[1]
[1] "Gallia est omnis divisa in partes tres, quarum unam incolunt Belgae, aliam Aquitani, tertiam qui ipsorum lingua Celtae, nostra Galli appellantur. Hi omnes lingua, institutis, legibus inter se differunt. Gallos ab Aquitanis Garumna flumen, a Belgis Matrona et Sequana dividit. Horum omnium fortissimi sunt Belgae, propterea quod a cultu atque humanitate provinciae longissime absunt, minimeque ad eos mercatores saepe commeant atque ea quae ad effeminandos animos pertinent important, proximique sunt Germanis, qui trans Rhenum incolunt, quibuscum continenter bellum gerunt. Qua de causa Helvetii quoque reliquos Gallos virtute praecedunt, quod fere cotidianis proeliis cum Germanis contendunt, cum aut suis finibus eos prohibent aut ipsi in eorum finibus bellum gerunt. Eorum una pars, quam Gallos obtinere dictum est, initium capit a flumine Rhodano, continetur Garumna flumine, Oceano, finibus Belgarum, attingit etiam ab Sequanis et Helvetiis flumen Rhenum, vergit ad septentriones. Belgae ab extremis Galliae finibus oriuntur, pertinent ad inferiorem partem fluminis Rheni, spectant in septentrionem et orientem solem. Aquitania a Garumna flumine ad Pyrenaeos montes et eam partem Oceani quae est ad Hispaniam pertinet; spectat inter occasum solis et septentriones."

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

get_text <- function(url) {
  # Sys.sleep(1)
  read_html(url) |> 
  html_elements(".mw-heading3+ p") |> 
  html_text2() |> 
  paste(collapse= " ")
}

Это займет некоторое время.

libri_text <- map(urls, get_text)

Соединим две таблицы.

libri_text <- libri_text |>
  flatten_chr() |> 
  as_tibble()

caesar <- libri |> 
  bind_cols(libri_text) |> 
  mutate(title = str_remove(title, "Commentarii de bello Gallico/"))

caesar

Сохраним подготовленный датасет для дальнейшего анализа.

save(caesar, file = "../data/caesar.Rdata")

Поздравляем, на этом закончился первый большой раздел нашего курса “Основы работы в R” 🎁. За восемь уроков вы познакомились с основными структурами данных в R, научились собирать и трансформировать данные, строить графики, писать функции и циклы, а также готовить html-отчеты о своих исследованиях. Впереди нас ждут методы анализа текстовых данных.