.infobox {
padding: 1em 1em 1em 4em;
background: aliceblue 5px center/3em no-repeat;
color: black;
}
8 Веб-скрапинг
Файлы html, как и XML, хранят данные в структурированном виде. Извлечь их позволяет пакет rvest
. С его помощью мы добудем архив телеграм-канала Antibarbari HSE. Канал публичный, и Telegram дает возможность скачать архив в формате html при помощи кнопки export (эта функция может быть недоступна на MacOS, в этом случае стоит попробовать Telegram Lite). Данные (в формате zip) для этого урока можно забрать по ссылке.
Эта глава опирается в основом на второе издание книги 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-правила (такие инфобоксы использованы в предыдущей версии курса):
Проще говоря, это инструкция, что делать с тем или иным элементом. Каждое правило CSS имеет две основные части — селектор и блок объявлений. Селектор, расположенный в левой части правила до знака {
, определяет, на какие части документа (возможно, специально обозначенные) распространяется правило. Блок объявлений располагается в правой части правила. Он помещается в фигурные скобки, и, в свою очередь, состоит из одного или более объявлений, разделённых знаком «;».
Селекторы CSS полезны для скрапинга, потому что они помогают вычленить необходимые элементы. Это работает так:
p
выберет все элементы <p>.title
выберет элементы с классом “title”#title
выберет все элементы с атрибутом id=‘title’
Важно: если изменится структура страницы, откуда вы скрапили информацию, то и код придется переписывать.
8.3 Чтение html
Чтобы прочесть файл html, используем одноименную функцию.
library(rvest)
<- list.files("../files/antibarbari_2024-08-18", pattern = "html", full.names = TRUE) antibarbari_files
Используем пакет purrr
, чтобы прочитать сразу три файла из архива.
library(tidyverse)
<- map(antibarbari_files, read_html) antibarbari_archive
8.4 Парсинг html: отдельные элементы
На следующем этапе важно понять, какие именно элементы нужны. Рассмотрим на примере одного сообщения. Для примера я сохраню этот элемент как небольшой отдельный html; rvest
позволяет это сделать (но внутри двойных кавычек должны быть только одинарные):
<- minimal_html("
example_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
. Пока пробуем на одном из них:
<- antibarbari_archive[[1]]
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= ...
Уже из этого набора узлов можем доставать все остальное.
<- tibble(id = archive_1 |>
archive_1_tbl 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.
Как вы уже поняли, теперь нам надо проделать то же самое для двух других файлов из архива антиварваров, а значит пришло время превратить наш код в функцию.
<- function(html_file){
scrape_antibarbari <- tibble(
messages_tbl 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
}
<- map_df(antibarbari_archive, scrape_antibarbari) messages_tbl
Вот что у нас получилось.
messages_tbl
8.6 Разведывательный анализ
Создатели канала не сразу разрешили подписывать посты, поэтому для первых нескольких десятков подписи не будет. Кроме того, в некоторых постах только фото, для них в столбце text – NA, их можно сразу отсеять.
<- messages_tbl |>
messages_tbl filter(!is.na(text))
messages_tbl
Также преобразуем столбец, в котором хранится дата и время. Разделим его на два и выясним, в какое время и день недели чаще всего публикуются сообщения.
<- messages_tbl |>
messages_tbl2 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
<- messages_tbl2 |>
summary1 group_by(year, month) |>
summarise(n = n())
summary1
<- messages_tbl2 |>
summary2 group_by(year, hour) |>
summarise(n = n()) |>
mutate(hour = case_when(hour == 0 ~ 24,
.default = hour))
summary2
<- messages_tbl2 |>
summary3 group_by(wday) |>
summarise(n = n())
summary3
library(gridExtra)
library(grid)
library(paletteer)
<- paletteer_d("khroma::okabeitoblack")
cols
<- summary1 |>
p1 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])
<- summary2 |>
p2 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])
<- summary3 |>
p3 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"))
<- messages_tbl2 |>
p4 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).
<- read_html("http://criticaldh.ru/program/")
html <- html |>
my_table html_table() |>
pluck(1)
my_table
8.8 Selector Gadget
Многие тексты доступны на сайте <wikisource.org>. Попробуем извлечь латинский текст “Записок о Галльской войне” Цезаря: он пригодится нам в следующем уроке.
<- "https://la.wikisource.org/wiki/Commentarii_de_bello_Gallico"
url = read_html(url) html
Для того, чтобы справиться с такой страницей, пригодится Selector Gadget (расширение для Chrome). Вот тут можно посмотреть короткое видео, как его установить. При помощи селектора выбираем нужные уровни.
<- html |>
toc 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-страниц.
<- tibble(
libri 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 для составления правила.
<- libri |>
urls pull(link)
<- read_html(urls[1]) |>
text html_elements(".mw-heading3+ p") |>
html_text2()
1] text[
[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."
Убедившись, что параграфы извлечены верно, обобщаем: пишем функцию для извлечения текстов и применяем ее ко всем книгам.
<- function(url) {
get_text # Sys.sleep(1)
read_html(url) |>
html_elements(".mw-heading3+ p") |>
html_text2() |>
paste(collapse= " ")
}
Это займет некоторое время.
<- map(urls, get_text) libri_text
Соединим две таблицы.
<- libri_text |>
libri_text flatten_chr() |>
as_tibble()
<- libri |>
caesar bind_cols(libri_text) |>
mutate(title = str_remove(title, "Commentarii de bello Gallico/"))
caesar
Сохраним подготовленный датасет для дальнейшего анализа.
save(caesar, file = "../data/caesar.Rdata")
Поздравляем, на этом закончился первый большой раздел нашего курса “Основы работы в R” 🎁. За восемь уроков вы познакомились с основными структурами данных в R, научились собирать и трансформировать данные, строить графики, писать функции и циклы, а также готовить html-отчеты о своих исследованиях. Впереди нас ждут методы анализа текстовых данных.