Недавно мне попалась книга «Applied Multivariate Statistical Analysis» за авторством Wolfgang K. Härdle, Léopold Simar. В книге приводится множество примеров кода со ссылками на данные. Но ни файлов с кодом. ни с данными у меня не оказалось. В самом начале книги я обнаружил следующий абзац, который вроде бы содержит ответ на мой вопрос, но всё оказалось не так просто. Итак читаем:

The majority of chapters have quantlet codes in Matlab or R. These quantlets may be downloaded from http://extras.springer.com or via a link on http://springer.com/978-3-662-45170-0 and from www.quantlet.de

Пройдя по адресу http://extras.springer.com и введя в поле поиска ISBN книги, я нашёл архив со скриптами. Проблема была в том. что там не было файлов данных. Поэтому я прошёл по второй ссылке на http://www.quantlet.de. Я сразу же нашёл нужный раздел и ужаснулся — там была куча ссылок на подстраницы, которые в свою очередь содержали ссылки на файлы с кодом и некоторые из них содержали ссылки на файлы данных. Заходить на каждую из них и скачивать файлы было для меня не приемлемо из-за большого количества этих самых страниц (как я узнал позже их там больше 200).

Итак, конкретизируем задачу. Нам необходимо:

  1. Извлечь со стартовой страницы все ссылки на подстраницы;
  2. Извлечь ссылки на файл со скриптом и файл с данными, если таковой есть;
  3. Загрузить файлы по полученным ранее ссылкам.

Конечно, такую задачу можно очень просто решить с помощью скрипта на bash или python, но мне стало интересно: насколько сложно будет это сделать в R. Раньше мне уже приходилось работать с содержимым веб-страниц и я знал, что в R, по сути, есть только один пакет, который решает эту задачу — это пакет XML. Незадолго до этого в твиттере я наткнулся на новость о том, что Hadley Wickham анонсировал очередной свой пакет под названием rvest, которые по словам автора призван сделать процесс анализа веб-страниц легким и приятным. В основе пакета rvest пакет XML и selectr, который позволяет добавить поддержку синтаксиса CSS к уже имеющемуся в XML Xpath, т.е. rvest — это «обёртка», сделанная ради удобства пользователя. Ну что ж, посмотрим так ли это.

Первым этапом при извлечении элементов веб-страницы — это извлечь (распарсить) содержимое всей веб-страницы. В пакете rvest для этого предназначена функция html().

library(rvest)
page <- html("http://www.quantlet.de/index.php?p=searchResults&w=book&id=141")

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

summary(page)
#> $nameCounts
#> 
#>     td      a    img     tr script    div      p  label     li   link     h2     th   body     h1   head   html  table  title     ul 
#>    576    298    289    289     19     11      9      8      4      4      2      2      1      1      1      1      1      1      1 
#> 
#> $numNodes
#> [1] 1518

Таким образом, мы получили представление о том в каком количестве на странице содержатся разные тэги. Напомню, что нашей первой подзадачей было извлечь ссылки на подстраницы со скриптами и данными. Итак, за гиперссылки в HTML отвечает тэг a. Пакет rvest поддерживает два синтаксиа для извлечения элементов страницы: CSS и XPath. В конце данного поста приведены ссылки на документацию по синтаксису обоих языков. Извлечём все ссылки с целевой страницы, используя синтаксис CSS:

sub_links <- html_nodes(page, "a")

Проанализировав содержимое полученной нами переменной, я обнаружил, что далеко не все ссылки мне нужны: некоторые из них ведут на другие разделы сайта, авторизацию и т. д. Я также заметил, что все ссылки на подстраницы, которые нам нужны, обозначены через class="img". Используя синтаксис CSS, в котором класс элемента обозначается как .class_name, извлечём из переменной page только те ссылки, которые относятся к классу img:

sub_links <- html_nodes(page, "a.img")

Пока что мы всего лишь извлекли содержимое тэгов a, относящиеся к классу img. Адреса ссылок содержатся в атрибуте href, извлесё их:

sub_links <- html_attr(sub_links, "href")

Давайте взглянем на то, что получилось:

head(sub_links)
#> [1] "index.php?p=show&id=1707" "index.php?p=show&id=1711" "index.php?p=show&id=1708" "index.php?p=show&id=1712" "index.php?p=show&id=1709" "index.php?p=show&id=1713"

Чего-то не хватает. А не хватает первой составляющей адреса — названия протокола и адреса сайта: http://www.quantlet.de/. Исправим это:

sub_links <- paste0("http://www.quantlet.de/", sub_links)

Чуть позже я узнал о более простом способе сделать то же самое с помощью пакета XML:

library(XML)
sub_links <-  getHTMLLinks("http://www.quantlet.de/index.php?p=searchResults&w=book&id=141",
                           xpQuery = "//a[@class='img']/@href", relative = TRUE)

Функция getHTMLLinks() может принимать как URL, так и уже структуриорванный объект, полученный в результате работы функции htmlParse(). Для извлечения значений атрибутов используется синтаксис XPath. Аргумент relative = TRUE добавляет имя сайта к извлечённым ссылкам.

Итак, первую подзадачу можно считать выполненной.

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

Проанализировав содержимое нескольких подстраниц я обнаружил, что ссылки на скачивание имеют класс help. Чтобы обработать сразу все подстраницы воспользуемся функцией lapply().

sub_links <- lapply(sub_links, function(x) html_nodes(html(x), "a.help"))

Имея опыт решение первой подзадачи мы с лёгкостью извлечём адреса ссылок:

down_links <- lapply(sub_links, function(x) html_attr(x, "href"))

Осталось только превратить полученный список в вектор и добавить адрес сайта:

down_links <- unlist(down_links)
down_links <- paste0("http://www.quantlet.de/", down_links)

Со ссылками для скачивания мы разобрались. Теперь встаёт вопрос: откуда нам взять имена файлов? Давайте рассмотрим содержание нескольких тэгов a:

head(sub_links, 3)
#> 
#> 
#> <a href="getDownload.php?a=quantlet&amp;id=1707&amp;name=MVACARTBan1&amp;type=R" class="downloadLink help" title="Click to download MVACARTBan1.R">Download File</a> 
#> 
#> 
#> <a href="getDownload.php?a=data&amp;id=419" title="Click to download bankruptcy.dat: " class="help">bankruptcy.dat</a> 
#> 
#> attr(,"class")
#> [1] "XMLNodeSet"
#> 
#> 
#> 
#> <a href="getDownload.php?a=quantlet&amp;id=1711&amp;name=MVACARTBan1&amp;type=m" class="downloadLink help" title="Click to download MVACARTBan1.m">Download File</a> 
#> 
#> 
#> <a href="getDownload.php?a=data&amp;id=419" title="Click to download bankruptcy.dat: " class="help">bankruptcy.dat</a> 
#> 
#> attr(,"class")
#> [1] "XMLNodeSet"
#> 
#> 
#> 
#> <a href="getDownload.php?a=quantlet&amp;id=1708&amp;name=MVACARTBan2&amp;type=R" class="downloadLink help" title="Click to download MVACARTBan2.R">Download File</a> 
#> 
#> 
#> <a href="getDownload.php?a=data&amp;id=419" title="Click to download bankruptcy.dat: " class="help">bankruptcy.dat</a> 
#> 
#> attr(,"class")
#> [1] "XMLNodeSet"

Обратите внимание на атрибут title: там содержится нечто вроде «Click to download MVAsimcibh.R». Как раз из него мы и получим имена файлов.

file_names <- lapply(sub_links, function(x) html_attr(x, "title"))
file_names <- unlist(file_names)

Теперь нам осталось избавиться от лишнего текста:

file_names <- gsub("^Click to download ", "", file_names)
file_names <- gsub(":.*", "", file_names)

Задача с извлечением имён файлов могла бы решаться и по другому, если бы имена файлов содержались в тексте, заключённом между тэгами a. Например, <a href="link">Click to download filename</a>. В таком случае для извлечения текста между двумя тэгами мы бы воспользовались функцией html_text() и наша конструкция выглядела бы следующим образом:

file_names <- lapply(sub_links, function(x) html_text(x))

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

down_links <- unique(down_links)
file_names <- unique(file_names)

Итак, у нас есть список ссылок для скачивания и список имён файлов, осталось дело за малым.

Скачать файлы при помощи встроенной функции в R можно следующим образом:

mapply(function(url, file) download.file(url, file, quiet = TRUE), down_links, file_names)

Файлы будут скачены в текущую директорию, поэтому убедитесь, что вы перешли в нужную рабочую директорию.

Главным недостатком вышеприведённого способа является то, что он очень медленный, т.к. файлы будут загружаться последовательно. Отчасти это можно компенсировать с помощью параллелизации процесса с использованием функции mcmapply() из пакета parallel. Но, на мой взгляд, лучше вовремя остановиться и использовать внешнюю утилиту, которая поддерживает параллельное скачивание файла по списку. Этот список мы легко можем сгенерировать с помощью функции writeLines():

writeLines(down_links, "filelist.txt")

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

library(rvest)
url <- "http://www.quantlet.de/index.php?p=searchResults&w=book&id=141"
base_url <- "http://www.quantlet.de/"
page <- html(url)
subpages <- page %>% html_nodes("a.img") %>% html_attr("href") %>%
    paste0(base_url, .)
links <- subpages %>% 
    lapply(function(x) html(x) %>% html_nodes("a.help"))
down_links <- subpages %>% 
    lapply(function(x) html_attr(x, "href")) %>% 
    unlist %>% unique %>% 
    paste0(base_url, .) 
file_names <- subpages %>% 
    lapply(function(x) html_attr(x, "title")) %>% 
    unlist %>% unique %>% 
    gsub("^Click to download ", "", .) %>% 
    gsub(":.*", "", .)
mapply(function(url, file) download.file(url, file, quiet = TRUE),
       down_links, file_names)

Для сравнения приведём решение той же задачи при помощи пакета XML:

library(XML)
url <- "http://www.quantlet.de/index.php?p=searchResults&w=book&id=141"
base_url <- "http://www.quantlet.de/"
subpages <- getHTMLLinks(url, xpQuery = "//a[@class='img']/@href", relative = TRUE)
down_links <- lapply(subpages, function(x) {
    getHTMLLinks(x, xpQuery = "//a[contains(@class, 'help')]/@href", relative = TRUE)
})
down_links <- unique(unlist(down_links))
file_names <- sapply(subpages, function(x) {
    getNodeSet(htmlParse(x), "//a[contains(@class, 'help')]/@title"))
})
file_names <- unique(unlist(file_names))
file_names <- gsub("^Click to download ", "", file_names)
file_names <- gsub(":.*", "", file_names)
mapply(function(url, file) download.file(url, file, quiet = TRUE),
       down_links, file_names)