В этой статье мы попытаемся с вами создать собственный простой терминальный клиент для нашего блога без всяких сторонних API и используя лишь одну стороннюю зависимость, и покажем как можно использовать некоторые возможности стандартной библиотеки языка программирования D.
Естественно, клиент будет оснащен лишь базовой функциональностью, но это не помешает вам в дальнейшем окунуться в захватывающий мир автоматического разбора веб-данных.
Для начала мы создадим простой проект dub с одной единственной зависимостью, которой является библиотека htmld:
dub init crawler
Эта библиотека поможет нам с разбором страницы блога и выделением из нее нужных элементов, а вся работа по выделению данных из разобранных элементов будет возложена на программиста.
Но, я таким образом осветил лишь часть стратегии по написанию клиента и не рассказал о том, как именно мы будем получать данные для последующей обработки. Дело в том, что с помощью стандартной библиотеки D можно осуществлять работу с сетью напрямую, а это не совсем то, что нам нужно, поэтому для скачивания данных мы применим модуль привязки к libcurl (библиотека для взаимодействия с различными сервисами с синтаксисом URL), который присутствует в виде модуля std.net.curl. Но libcurl нам нужна лишь для того, чтобы посредством обычного GET-запроса получить содержимое первой страницы блога LightHouse Software и отдать полученный результат в виде обычного текста, внутри которого содержится HTML-разметка. Эту разметку мы будем разбирать частично при помощи htmld — библиотека позволит получить из текста уже полноценный HTML-документ, который представлен древовидной структурой, а также провести ряд запросов к дереву документа, избавив себя от мучений от поиска ключевых условий выделения некоторых элементов; и частично с помощью средств языка программирования D: цепочек выполнения функций, наборов преобразования строк и средств функционального программирования.
Но перед тем, как делать разбор данных с нашего сайта, необходимо выяснить структуру тех данных, с которыми предстоит работать. Для этого, с помощью любого доступного браузера просто сохраняем главную страничку сайта LightHouse Software и открываем ее в текстовом редакторе, который поддерживает подсветку синтаксиса.
Однозначно, благодаря подсветке синтаксиса можно заметить, что на странице сайта присутствует не только HTML-разметка и разного рода клиентские скрипты, но и некое подобие XML, поскольку встречаются теги, которых в обычном HTML нет. И это наблюдение способно нам помочь: дело в том, что по сути дела мы получаем своеобразный «снимок» веб-странички, на тот момент, когда ее сохраняем, и в этом «снимке» присутствуют весьма интересный тег article. И как ни странно, в этот тег упакован краткий анонс самой статьи (это то, что вы видите на главной странице как краткий предварительный текст), заголовок статьи, дата выхода и никнейм автора…
Таким образом, наш терминальный клиент будет выполнять получение актуальной копии главной странички блога, извлекать из копии нужный тег статьи и выводить собранную информацию в читаемом виде прямо в терминал.
Импортируем необходимые нам для обработки модули Phobos и htmld, а также создаем два перечисления, которые послужат нам для обозначения адреса сайта и для обозначения «мусорных» элементов в текстовом описании статьи:
import std.algorithm; import std.conv; import std.range; import std.stdio; import std.string; import std.net.curl; import html; enum const(char)[] LHS_BLOG = `http://lhs-blog.info`; enum string[] JUNK = [ "Оставить комментарий", "Подробнее", "Читать далее →" ];
«Мусорные» элементы можно было пронаблюдать в сохраненной веб-страничке, и таковыми являются надписи на кнопках, ссылках и тому подобные технические элементы дизайна сайта. В список таких элементов попали следующие надписи: «Оставить комментарий», «Читать далее ->» и «Подробнее», такие надписи нам в терминальном выводе просто не нужны, т.к. загрязняют лишней информацией вывод сводки.
Теперь, перейдем к формированию общего потока программы, который описывается следующим кодом:
void main() { LHS_BLOG .get .to!string .createDocument .querySelectorAll(`article`) .map!(a => a.text.to!string.strip) .map!(a => a.replaceJunk(JUNK)) .map!(a => a.parseArticleData) .each!(a => a.writeln); }
Сначала мы с помощью метода get выполняем обычный GET-запрос к сайту (использование UFCS в данном случае несколько ухудшает читаемость кода, поскольку можно подумать будто вызывается некий метод-геттер у строкового объекта), а затем переводим его результат из типа const(char)[] в тип string с помощью уже знакомого нам шаблона to из std.conv. Далее с помощью функции createDocument из htmld мы создаем из полученного текста объект, который содержит HTML-разметку, и к которому мы с помощью метода querySelectorAll выполняем запрос извлечения ВСЕХ тегов article из полученного HTML-документа, сгенерировав таким образом целый диапазон объектов, содержащих целевые теги. Следующей операцией мы проходим по диапазону с помощью алгоритма map, который выполняет вычленение текстового содержимого, убранного в тег, посредством вызова метода text (метод объекта тега из htmld) с последующим преобразованием в строку шаблоном to (на самом деле, это скрытый вызов метода toString, который вызывается для получения более читаемого вывода) и обрезанием «пробельных символов» с обоих концов каждого приведенного в строковый вид элемента. Далее с помощью того же самого алгоритма map и функции replaceJunk (об этом расскажу подробнее далее) убираем из полученных строковых данных «мусорные» элементы, а затем с помощью функции parseArticleData компонуем результат предыдущего шага в диапазон объектов информационной сводки по актуальным на данный момент статьям блога LightHouse Software. И после всех этих интригующих преобразований данных с помощью алгоритма each (еще один замаскированный вызов метода toString) выводим наконец краткую информацию по статьям.
Теперь, вернемся к некоторым деталям алгоритма, а именно, к функциям, которые пришлось создавать…
Первой такой функцией является функция replaceJunk, которая выполняет очистку строки от некоторых «мусорных элементов», представленных массивом строк, код которой выглядит примерно так:
auto replaceJunk(string source, string[] junk) { junk.each!(a => source = source.replace(a, " ")); return source; }
Здесь, я впервые применил своеобразный финт: вместо использования UFCS для первого аргумента (который обычно и является целевым аргументом), я задействую униформный вызов функции для второго аргумента. Это позволяет пройтись по списку «мусорных» элементов и с помощью replace удалить конкретный «мусорный» элемент, заменив его простым пробелом. Повторное присваивание внутри алгоритма each гарантирует изменение первого поступившего аргумента (вспоминаем про то, что в этом случае функция оперирует собственной копией аргумента) и возвращение его уже очищенным от ненужной нам информации.
Прежде чем перейти к следующей функции, а именно функции parseArticleData, нам нужно будет внести в наш код один шаблон, разработанный нами для удобной генерации сеттеров и геттеров для некоторых объектов (C# привет !), который мы уже неоднократно упоминали:
// convenient wrapper template addProperty(string propertyVariableName, string variableType, string propertyName) { import std.string : format; const char[] addProperty = format( ` private %2$s %1$s; void set%3$s(%2$s %1$s) { this.%1$s = %1$s; } %2$s get%3$s() { return %1$s; } `, propertyVariableName, variableType, propertyName ); }
А теперь приведем код функции parseArticleData, которая из строки с текстовым содержимым одного тега article, создает структуру типа Article, который мы будем использовать как безымянный тип (т.е. мы не будем его создавать напрямую где-то снаружи самой функции, поскольку такое создание нам не нужно):
auto parseArticleData(string source) { // article struct Article { mixin(addProperty!("title", "string", "Title")); mixin(addProperty!("date", "string", "Date")); mixin(addProperty!("author", "string", "Author")); mixin(addProperty!("content", "string", "Content")); string toString() { return format( " \u001b[32m% 1$s \u001b[0m %2$s by %3$s " ~ content, title, date, author ); } } auto predata = source .chomp .chop .split("\n\t\t\t"); auto article = Article(); auto da = predata[2].strip; auto datePosition = da.lastIndexOf(" "); auto pdate = da[0..(datePosition - 1)/2]; with (article) { setTitle(predata[0].strip); setDate(pdate); setAuthor(da[datePosition+1..$]); setContent(predata[3..$].join.to!string); } return article; }
Для начала мы просто создаем саму структуру Article, добавляем в нее необходимые сеттеры и геттеры, которые служат для внесения/извлечения заголовка статьи, даты публикации, автора и краткого описания статьи. Также, добавляем метод toString, который в более понятной форме отобразит вышеупомянутые поля структуры.
После создания структуры выполняем предварительную подготовку исходных данных, очистив их от начальных и конечных «пробельных символов с помощью функций chop/chomp, а также выполнив их разбиение с учетом того, что интересующие нас данные разделены комбинацией из перевода строки и трех табуляций (этот факт был выяснен исследованием сохраненной копии странички сайта, о которой я уже говорил выше). Результат обработки помещается в переменную predata.
Второй элемент в массиве predata — это продублированная дата публикации (прямая конкатенация строки с датой с самой собой), пробельный символ, и никнейм автора публикации. Поэтому выполняем удаление пробельных символов с начала и конца для этого элемента, сохранив результат в переменную da. После этого, определяем позицию пробела начиная С КОНЦА СТРОКИ (этот пробел служит маркером для отделения никнейма от даты, и его индекс помещается в переменную datePosition) и извлекаем дату публикации воспользовавшись срезом строки. Поскольку дата повторяется дважды без каких-либо символов разделения, а кончается дата как раз тем самым пробелом, то для того, чтобы извлечь дату нужно сделать срез строки da начиная от нулевого ее элемента и кончая элементом с номером (datePosition — 1) / 2. Результат среза помещается в переменную pdate.
С помощью конструкции with мы сокращая количество упоминаний экземпляра структуры article, мы заполняем ее поля, используя тот факт, что заголовок статьи размещен в predata под индексом 0. Поскольку, дата уже извлечена, то сама дата помещается в структуру просто передачей переменной pdate в автоматически сгенерированный сеттер setDate. После этого, легко получить и никнейм автора статьи, просто вспоминив то, что позиция маркера-разделителя (тот самый пробел, индекс которого равен datePosition) уже известна: для чего просто выполняем срез строки da с индексами datePosition+1 и $ (т.е до конца строки).
Содержимое краткого анонса статьи в нашем случае представляет собой все то, что осталось в массиве predata после индекса 2, и поэтому, мы просто собираем все что после этого индекса в единую строку с помощью алгоритма join и шаблона to.
После всех этих непростых преобразований мы просто возвращаем заполненную структуру типа Article.
Теперь, можно переходить к испытаниям, для чего достаточно просто осуществить сборку и запуск полученного приложения:
Полный код примера:
import std.algorithm; import std.conv; import std.range; import std.stdio; import std.string; import std.net.curl; import html; enum const(char)[] LHS_BLOG = `http://lhs-blog.info`; enum string[] JUNK = [ "Оставить комментарий", "Подробнее", "Читать далее →" ]; auto replaceJunk(string source, string[] junk) { junk.each!(a => source = source.replace(a, " ")); return source; } // convenient wrapper template addProperty(string propertyVariableName, string variableType, string propertyName) { import std.string : format; const char[] addProperty = format( ` private %2$s %1$s; void set%3$s(%2$s %1$s) { this.%1$s = %1$s; } %2$s get%3$s() { return %1$s; } `, propertyVariableName, variableType, propertyName ); } auto parseArticleData(string source) { // article struct Article { mixin(addProperty!("title", "string", "Title")); mixin(addProperty!("date", "string", "Date")); mixin(addProperty!("author", "string", "Author")); mixin(addProperty!("content", "string", "Content")); string toString() { return format( " \u001b[32m% 1$s \u001b[0m %2$s by %3$s " ~ content, title, date, author ); } } auto predata = source .chomp .chop .split("\n\t\t\t"); auto article = Article(); auto da = predata[2].strip; auto datePosition = da.lastIndexOf(" "); auto pdate = da[0..(datePosition - 1)/2]; with (article) { setTitle(predata[0].strip); setDate(pdate); setAuthor(da[datePosition+1..$]); setContent(predata[3..$].join.to!string); } return article; } void main() { LHS_BLOG .get .to!string .createDocument .querySelectorAll(`article`) .map!(a => a.text.to!string.strip) .map!(a => a.replaceJunk(JUNK)) .map!(a => a.parseArticleData) .each!(a => a.writeln); }
Надеюсь, что эта статья поможет вам начать свой путь к автоматическому сбору информации с веб-сайтов с помощью D.