Как выкачать свежие анонсы опубликованных статей блога «LightHouse Software»

В этой статье мы попытаемся с вами создать собственный простой терминальный клиент для нашего блога без всяких сторонних 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.

aquaratixc

Программист-самоучка и программист-любитель

Добавить комментарий