Тема 9 Веб-скрапинг

Выше мы говорили о таком импорте html, когда все теги разом удаляются. Это не всегда удобно, поскольку файл хранит данные в структурированном виде, например, под разными тегами дату, автора и текст. И может быть желательно эту структуру сохранить.

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

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

9.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, которые хранятся у вас на компьютере).

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

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

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

У этого курса тоже есть свой файл .css, в котором блок infobox (вы его видите как серый квадратик с определением) описан так:

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

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

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

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

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

9.3 Извлечение данных

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

library(rvest)
messages <- read_html("./files/antibarbari_archive/messages.html")
messages2 <- read_html("./files/antibarbari_archive/messages2.html")

messages
## {html_document}
## <html>
## [1] <head>\n<meta http-equiv="Content-Type" content="text ...
## [2] <body onload="CheckLocation();">\n\n  <div class="pag ...

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

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=‘signature details’>). Извлекаем текст (для этого рекомендуется использовать функцию html_text2()):

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

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

html %>%
  html_element(".signature.details") %>% 
  html_text2()
## [1] "Olga Alieva"

Важно помнить, что html_element всегда возвращает один элемент. Если их больше, надо использовать html_elements.

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

html %>%
  html_element("div") %>% 
  html_attr("id")
## [1] "message83"

9.4 Извлечение в тиббл

library(tidyverse)

tibble(id = html %>% 
         html_element("div") %>% 
         html_attr("id"),
       signature = html %>%
         html_element(".signature.details") %>% 
         html_text2(),
       text = html %>% 
         html_element(".text") %>%
         html_text2()
)
## # A tibble: 1 × 3
##   id        signature   text                                
##   <chr>     <chr>       <chr>                               
## 1 message83 Olga Alieva "Этот пост открывает серию переложе…

9.5 Скрапим телеграм-канал

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

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

Уже из этого списка можем доставать все остальное.

messages_tbl1 <- tibble(id = messages %>% 
         html_elements("div.message.default") %>% 
         html_attr("id"),
       signature = messages %>%
         html_elements("div.message.default") %>% 
         html_element(".signature.details") %>% 
         html_text2(),
       text = messages %>% 
         html_elements("div.message.default") %>% 
         html_element(".text") %>%
         html_text2()
)

messages_tbl2 <- tibble(id = messages2 %>% 
         html_elements("div.message.default") %>% 
         html_attr("id"),
       signature = messages2 %>%
         html_elements("div.message.default") %>% 
         html_element(".signature.details") %>% 
         html_text2(),
       text = messages2 %>% 
         html_elements("div.message.default") %>% 
         html_element(".text") %>%
         html_text2()
)

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

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

messages_tbl <- messages_tbl1 %>% 
  bind_rows(messages_tbl2)

messages_tbl
## # A tibble: 1,096 × 3
##    id        signature text                                 
##    <chr>     <chr>     <chr>                                
##  1 message3  <NA>      "Latin never sleeps. Новое видео на …
##  2 message5  <NA>      "Подборка видео семинара по медленно…
##  3 message6  <NA>      "Новое видео в плейлисте \"Латинский…
##  4 message7  <NA>      "🤖 ОТКРЫТА ЗАПИСЬ НА ПРОЕКТ\n\nВ ра…
##  5 message8  <NA>      "Желающие присоединиться к группе, ч…
##  6 message9  <NA>      "https://youtu.be/I-U_lG0mB3M"       
##  7 message10 <NA>      "В клубе Antibarbari продолжается се…
##  8 message11 <NA>      "Филеб. Семинар 3 марта 2022. Сократ…
##  9 message12 <NA>      "Вышка вернулась в аудитории, поэтом…
## 10 message13 <NA>      "Воспользовались дополнительным выхо…
## # ℹ 1,086 more rows

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

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


Извлеките из архива антиварваров дату публикации для каждого поста.

9.6 Эмотиконы

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

library(emoji)

messages_tbl %>% 
  mutate(emoji = emoji_extract_all(text)) %>% 
  pull(emoji) %>% 
  unlist() %>% 
  as_tibble() %>%
  count(value) %>% 
  arrange(-n) 
## # A tibble: 159 × 2
##    value     n
##    <chr> <int>
##  1 👾       68
##  2 ⭐       23
##  3 👀       21
##  4 👇       20
##  5 🍿       18
##  6 📚       16
##  7 🔼       12
##  8 1️⃣        10
##  9 📌       10
## 10 🔗       10
## # ℹ 149 more rows

Заменяем их все на пробелы.

messages_tbl <- messages_tbl %>% 
  mutate(text = emoji_replace_all(text, " "))

9.7 Рутинная уборка

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

example <- messages_tbl$text[340]

example
## [1] "🎞 Теэтет #10 149b7-150b8\nСократ продолжает засыпать семнадцатилетнего математика подробностями из области акушерства и гинекологии, и мы вместе с ним терпеливо изучаем, чем сводничество отличается от сватовства. Верните квадратные корни. #платон #теэтет\nhttps://vk.com/video-211800158_456239238"

Вот так вылавливается гиперссылка.

str_extract_all(example, "(http|https)(\\S+)")
## [[1]]
## [1] "https://vk.com/video-211800158_456239238"

Вот так вылавливается пагинация и номер семинара (и некоторые другие числа).

str_extract_all(example, "#?\\d{2,3}\\w?\\d?-?")
## [[1]]
## [1] "#10"    "149b7-" "150b8"  "21180"  "0158"   "45623" 
## [7] "9238"

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

messages_clean <- messages_tbl %>% 
  mutate(text = str_replace_all(text, "(http|https)(\\S+)", " ")) %>% 
  mutate(text = str_replace_all(text, "\\d{2}\\.\\d{2}\\.\\d{4}", " ")) %>% 
  mutate(text = str_replace_all(text, "\\W[-A-Za-z0-9_.%]+\\@[-A-Za-z0-9_.%]+\\.[A-Za-z]", " ")) %>% 
  mutate(text = str_replace_all(text, "#?\\d{2,3}\\w?\\d?-?", " ")) %>% 
  mutate(text = str_replace_all(text, "\\n+", " "))

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

messages_clean %>% 
  filter(row_number() == 340)
## # A tibble: 1 × 3
##   id         signature   text                               
##   <chr>      <chr>       <chr>                              
## 1 message465 Olga Alieva "🎞 Теэтет      Сократ продолжает з…

Число id и число текстов не совпадает, поскольку для некоторых постов текста нет (NA), а у других он совпадает (“Пост выходного дня”). Это надо сразу исправить, чтобы результат лемматизации можно было потом соединить с данными о подписи. Я просто уберу очень короткие посты, поскольку для анализа они неинтересны.

messages_clean <- messages_clean %>%
  filter(nchar(text) > 19)
dim(messages_clean)
## [1] 734   3

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

messages_clean <- messages_clean %>% 
  rename(doc_id = id) %>% 
  mutate(doc_id = paste0("doc", row_number()))

9.8 Лемматизация

На лемматизацию мы отдаем вектор с сообщениями.

library(udpipe)
russian_syntagrus <- udpipe_load_model(file = "russian-syntagrus-ud-2.5-191206.udpipe")

messages_ann <- udpipe_annotate(russian_syntagrus, messages_clean$text)

messages_ann <- as_tibble(messages_ann)

messages_ann

Убедимся, что после лемматизации число id не изменилось, и соединим этот тиббл с данными о подписи.

length(unique(messages_ann$doc_id))
## [1] 734
messages_signed <- messages_ann %>% 
  left_join(messages_clean, by = "doc_id") %>% 
  select(-text, -sentence_id, -paragraph_id, -xpos, -feats,
         -head_token_id, -dep_rel, -deps, -misc)

length(unique(messages_signed$doc_id))
## [1] 734

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

# valid_names <- messages_ann %>% 
#   filter(upos == "PROPN") %>% 
#   count(lemma) %>% 
#   arrange(-n) %>% 
#   filter(!str_detect(lemma, "[\\.«]")) %>% 
#   filter(str_detect(lemma, "[[\u0400-\u04FF]]")) %>% 
#   filter(n > 1)
# 
# valid_names_vec <- as.character(valid_names$lemma)

# список имен отредактирован вручную
# write.table(valid_names_vec, file = "files/names.txt", 
#            row.names = F, col.names = F)
valid_names <- read_table(file = "files/names.txt", col_names = F)

valid_names <- valid_names %>% 
  rename(names = X1) %>% 
  mutate(names = str_remove_all(names, "\\W")) %>% 
  pull(names)
messages_signed <- messages_signed %>% 
  filter(upos != "PROPN" | upos == "PROPN" & lemma %in% valid_names) %>% 
  filter(upos != "PUNCT") %>% 
  filter(!upos %in% c("X", "NUM")) %>% 
  mutate(lemma = str_replace_all(lemma, "-", "")) %>% 
  mutate(lemma = str_remove_all(lemma, "[[:punct:]]"))

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

messages_signed <- messages_signed %>% 
  mutate(lemma = str_replace_all(lemma, "[[^\u0400-\u04FF]]", " ")) %>% 
  filter(nchar(lemma) > 0) 

Дальше были исправлены некоторые ошибки лемматизации.

text_tidy <- messages_signed %>%
  mutate(lemma = tolower(lemma)) %>% 
  mutate_at(vars(lemma), ~
       case_when(lemma == "мят" ~  "мята",
                 str_detect(lemma, "сенек") ~ "сенека",
                 str_detect(lemma, "аттик") ~ "аттик",
                 str_detect(lemma, "кальвиз") ~ "кальвизий",
                 str_detect(lemma, "горац") ~ "гораций",
                 str_detect(lemma, "гален") ~ "гален",
                 str_detect(lemma, "плотин") ~ "плотин",
                 str_detect(lemma, "августин") ~ "августин",
                 str_detect(lemma, "абари") ~ "абарид",
                 str_detect(lemma, "катулл") ~ "катулл",
                 str_detect(lemma, "лукул") ~ "лукулл",
                 str_detect(lemma, "посидо") ~ "посидоний",
                 str_detect(lemma, "деркилл") ~ "деркиллид",
                 str_detect(lemma, "порфир") ~ "порфирий",
                 str_detect(lemma, "афин") ~ "афины",
                 str_detect(lemma, "локк") ~ "локк",
                 str_detect(lemma, "макроб") ~ "макробий",
                 str_detect(lemma, "лаэр") ~ "лаэрций",
                 str_detect(lemma, "макрин") ~ "макрина",
                 str_detect(lemma, "маркиш") ~ "маркиш",
                 str_detect(lemma, "маячк") ~ "маячок",
                 str_detect(lemma, "очерк") ~ "очерк",
                 str_detect(lemma, "птолем") ~ "птолемей",
                 str_detect(lemma, "росса") ~ "росс",
                 str_detect(lemma, "самосат") ~ "самосата",
                 str_detect(lemma, "стрепсиад") ~ "стрепсиад",
                 str_detect(lemma, "эпихар") ~ "эпихарм",
                 str_detect(lemma, "ямвл") ~ "ямвлих",
                 str_detect(lemma, "брумал") ~ "брумалии",
                 str_detect(lemma, "иоанн") ~ "иоанн",
                 str_detect(lemma, "кинопоиск") ~ "кинопоиск",
                 str_detect(lemma, "корнар") ~ "корнарий",
                 str_detect(lemma, "луция") ~ "луций",
                 str_detect(lemma, "кассия") ~ "кассий",
                 str_detect(lemma, "минф") ~ "минфа",
                 str_detect(lemma, "персефон") ~ "персефона",
                 str_detect(lemma, "пестум") ~ "пестум",
                 str_detect(lemma, "платон") ~ "платон",
                 str_detect(lemma, "платно") ~ "платон",
                 str_detect(lemma, "филеб") ~ "филеб",
                 str_detect(lemma, "фульг") ~ "фульгенций",
                 str_detect(lemma, "аврел") ~ "аврелий",
                 str_detect(lemma, "антигон") ~ "антигона",
                 str_detect(lemma, "анция") ~ "анций",
                 str_detect(lemma, "борея") ~ "борей",
                 str_detect(lemma, "вольтер") ~ "вольтер",
                 str_detect(lemma, "гераклид") ~ "гераклид",
                 str_detect(lemma, "теэтет") ~ "теэтет",
                 str_detect(lemma, "евангел") ~ "евангелие",
                 str_detect(lemma, "федон") ~ "федон",
                 TRUE ~ .))

Получившийся тиббл сохраняю – он понадобится в главе 13.

save(text_tidy, file = "data/AntibarbariTidy.Rdata")

9.9 Html таблицы

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

Таблицы HTML строятся из четырех основных элементов: <table>, <tr> (строка таблицы), <th> (заголовок таблицы) и <td> (данные таблицы). Мы соберем информацию о проектных группах ФГН в 2022-2024 г.

html <- read_html("https://hum.hse.ru/proj/project2022_2024")
my_table <- html %>%  
  html_element(".bordered") %>% 
  html_table()

DT::datatable(my_table)


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


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

9.10 Wikisource

Многие тексты доступны на сайте Wikisource.org. Попробуем извлечь все сказки Салтыкова-Щедрина.

url <- "https://ru.wikisource.org/wiki/%D0%9C%D0%B8%D1%85%D0%B0%D0%B8%D0%BB_%D0%95%D0%B2%D0%B3%D1%80%D0%B0%D1%84%D0%BE%D0%B2%D0%B8%D1%87_%D0%A1%D0%B0%D0%BB%D1%82%D1%8B%D0%BA%D0%BE%D0%B2-%D0%A9%D0%B5%D0%B4%D1%80%D0%B8%D0%BD"
html = read_html(url)

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

toc <- html %>% 
  html_elements("ul:nth-child(22) a")

head(toc)
## {xml_nodeset (6)}
## [1] <a href="/wiki/%D0%9F%D0%BE%D0%B2%D0%B5%D1%81%D1%82%D ...
## [2] <a href="/wiki/%D0%93%D0%BE%D0%B4%D0%BE%D0%B2%D1%89%D ...
## [3] <a href="/wiki/%D0%9F%D1%80%D0%BE%D0%BF%D0%B0%D0%BB%D ...
## [4] <a href="/wiki/%D0%94%D0%B8%D0%BA%D0%B8%D0%B9_%D0%BF% ...
## [5] <a href="/wiki/%D0%9F%D1%80%D0%B5%D0%BC%D1%83%D0%B4%D ...
## [6] <a href="/wiki/%D0%A1%D0%B0%D0%BC%D0%BE%D0%BE%D1%82%D ...

Теперь у нас есть список ссылок.

tales <- tibble(
  title = toc %>%
    html_attr("title"),
  href = toc %>% 
    html_attr("href")
)

Данных о годе публикации под тегом нет; надо подняться на уровень выше:

toc2 <- html %>% 
  html_elements("ul:nth-child(22) li")

head(toc2)
## {xml_nodeset (6)}
## [1] <li>\n<a href="/wiki/%D0%9F%D0%BE%D0%B2%D0%B5%D1%81%D ...
## [2] <li>\n<a href="/wiki/%D0%93%D0%BE%D0%B4%D0%BE%D0%B2%D ...
## [3] <li>\n<a href="/wiki/%D0%9F%D1%80%D0%BE%D0%BF%D0%B0%D ...
## [4] <li>\n<a href="/wiki/%D0%94%D0%B8%D0%BA%D0%B8%D0%B9_% ...
## [5] <li>\n<a href="/wiki/%D0%9F%D1%80%D0%B5%D0%BC%D1%83%D ...
## [6] <li>\n<a href="/wiki/%D0%A1%D0%B0%D0%BC%D0%BE%D0%BE%D ...
toc2 %>%
  html_text2()
##  [1] "Повесть о том, как один мужик двух генералов прокормил, 1869"
##  [2] "Годовщина, 1869"                                             
##  [3] "Пропала совесть, 1869"                                       
##  [4] "Дикий помещик, 1869"                                         
##  [5] "Премудрый пискарь, 1883"                                     
##  [6] "Самоотверженный заяц, 1883"                                  
##  [7] "Бедный волк, 1883"                                           
##  [8] "Добродетели и Пороки, 1884"                                  
##  [9] "Медведь на воеводстве, 1884"                                 
## [10] "Обманщик-газетчик и легковерный читатель, 1884"              
## [11] "Вяленая вобла, 1884"                                         
## [12] "Орёл-меценат, 1884"                                          
## [13] "Карась-идеалист, 1884"                                       
## [14] "Игрушечного дела людишки, 1879, 1886"                        
## [15] "Чижиково горе, 1884"                                         
## [16] "Верный Трезор, 1885"                                         
## [17] "Недреманное око, конец 1885 или начало 1886"                 
## [18] "Дурак, 1885"                                                 
## [19] "Соседи, 1885"                                                
## [20] "Здравомысленный заяц, 1885"                                  
## [21] "Либерал, 1885"                                               
## [22] "Баран-непомнящий, 1885"                                      
## [23] "Коняга, 1855"                                                
## [24] "Кисель, 1855"                                                
## [25] "Праздный разговор, 1886"                                     
## [26] "Деревенский пожар, 1885"                                     
## [27] "Путём-дорогою, 1886"                                         
## [28] "Богатырь, 1886"                                              
## [29] "Гиена, 1886"                                                 
## [30] "Приключение с Крамольниковым, 1886"                          
## [31] "Христова ночь, 1886"                                         
## [32] "Ворон-челобитчик, 1886"                                      
## [33] "Рождественская сказка, 1886"

Соединяем:

tales <- tibble(
  title_year = toc2 %>%
    html_text2(),
  href = toc %>% 
    html_attr("href")
)

tales
## # A tibble: 33 × 2
##    title_year                                          href 
##    <chr>                                               <chr>
##  1 Повесть о том, как один мужик двух генералов проко… /wik…
##  2 Годовщина, 1869                                     /wik…
##  3 Пропала совесть, 1869                               /wik…
##  4 Дикий помещик, 1869                                 /wik…
##  5 Премудрый пискарь, 1883                             /wik…
##  6 Самоотверженный заяц, 1883                          /wik…
##  7 Бедный волк, 1883                                   /wik…
##  8 Добродетели и Пороки, 1884                          /wik…
##  9 Медведь на воеводстве, 1884                         /wik…
## 10 Обманщик-газетчик и легковерный читатель, 1884      /wik…
## # ℹ 23 more rows

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

url_test <- tales %>% 
  filter(row_number() == 1) %>% 
  pull(href) %>% 
  paste0("https://ru.wikisource.org", .)

text <- read_html(url_test) %>% 
  html_elements(".indent p") %>% 
  html_text2() 

text[1]
## [1] "Жили да были два генерала, и так как оба были легкомысленны, то в скором времени, по щучьему велению, по моему хотению, очутились на необитаемом острове."
text[length(text)]
## [1] "Однако, и об мужике не забыли; выслали ему рюмку водки да пятак серебра: веселись, мужичина!"

Первый и последний параграф достали верно; можно обобщать.

tales <- tales %>% 
    mutate(href = paste0("https://ru.wikisource.org", href))
urls <- tales %>% 
  pull(href)

Функция для извлечения текстов.

get_text <- function(url) {
  read_html(url) %>% 
  html_elements(".indent p") %>% 
  html_text2() %>% 
  paste(collapse= " ")
}
tales_text <- map(urls, get_text)

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

tales_text <- tales_text %>%
  flatten_chr() %>% 
  as_tibble()

tales <- tales %>% 
  bind_cols(tales_text)
tales
## # A tibble: 33 × 3
##    title_year                                           href  value
##    <chr>                                                <chr> <chr>
##  1 Повесть о том, как один мужик двух генералов прокор… http… "Жил…
##  2 Годовщина, 1869                                      http… "Сег…
##  3 Пропала совесть, 1869                                http… "Про…
##  4 Дикий помещик, 1869                                  http… "В н…
##  5 Премудрый пискарь, 1883                              http… "Жил…
##  6 Самоотверженный заяц, 1883                           http… "Одн…
##  7 Бедный волк, 1883                                    http… "Дру…
##  8 Добродетели и Пороки, 1884                           http… "Доб…
##  9 Медведь на воеводстве, 1884                          http… ""   
## 10 Обманщик-газетчик и легковерный читатель, 1884       http… "Жил…
## # ℹ 23 more rows

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

tales <- tales %>% 
  select(-href) %>% 
  separate(title_year, into = c("title", "year"), sep = -5) %>% 
  mutate(title = str_remove(title, ",$"))
tales
## # A tibble: 33 × 3
##    title                                                year  value
##    <chr>                                                <chr> <chr>
##  1 Повесть о том, как один мужик двух генералов прокор… " 18… "Жил…
##  2 Годовщина                                            " 18… "Сег…
##  3 Пропала совесть                                      " 18… "Про…
##  4 Дикий помещик                                        " 18… "В н…
##  5 Премудрый пискарь                                    " 18… "Жил…
##  6 Самоотверженный заяц                                 " 18… "Одн…
##  7 Бедный волк                                          " 18… "Дру…
##  8 Добродетели и Пороки                                 " 18… "Доб…
##  9 Медведь на воеводстве                                " 18… ""   
## 10 Обманщик-газетчик и легковерный читатель             " 18… "Жил…
## # ℹ 23 more rows

Недостающие две сказки я не буду пытаться извлечь, но логику вы поняли.