воскресенье, 24 апреля 2011 г.

Допиливаем HtmlAgilityPack. Часть 1

HtmlAgilityPack - пожалуй, самый популярный парсер HTML под .NET. В транке SVN'а лежит версия 1.4, но я бы посоветовал использовать версию 2 из бранчи. Я внес некоторые изменения в эту ветку и зарепортил их в виде патча. Пока неизвестно, попадут эти изменения в репозиторий или нет, поэтому вкратце опишу их в этом посте: может, тебе, мой дорогой читатель, эти изменения тоже пригодятся.

Центром библиотеки является класс HtmlDocument. В юнит-тестах у меня сплошь да рядом встречалась такая коснтрукция

[Test]
public void Test1()
{
var doc = new HtmlDocument();
doc.LoadHtml(SomeResource.SomePredefinedHtmlString);
...
}

Чтобы избавиться от лишней строчки, я добавил в HtmlDocument статический метод CreateFromHtml:

public static HtmlDocument CreateFromHtml(string html)
{
var document = new HtmlDocument();
document.LoadHtml(html);
return document;
}

Тогда тест приобретет следующий вид:

[Test]
public void Test1()
{
var doc = HtmlDocument.CreateFromHtml(SomeResource.SomePredefinedHtmlString);
...
}

Каждая нода документа позволяет выполнить XPath-запрос посредством методов SelectNodes и SelectSingleNode. Давайте остановимся на текущей реализации второго метода:

public IHtmlBaseNode SelectSingleNode(string xpath)
{
if (xpath == null)
{
throw new ArgumentNullException("xpath");
}

HtmlNodeNavigator nav = new HtmlNodeNavigator(_ownerdocument, this);
XPathNodeIterator it = nav.Select(xpath);
if (!it.MoveNext())
{
return null;
}

HtmlNodeNavigator node = (HtmlNodeNavigator)it.Current;
return node.CurrentNode;
}

Хм... Так не пойдет. Single - это один и только один. Давайте исправим логику метода SelectSingleNode, а для удобства реализуем еще и SelectSingleNodeOrDefault, SelectFirstNode и SelectFirstNodeOrDefault - в лучших традициях LINQ:

public IHtmlBaseNode SelectSingleNode(string xpath)
{
var it = CreateXPathNodeIterator(xpath);

if (!it.MoveNext()) // 0
throw new InvalidOperationException("Sequence contains no elements");

var nodeNavigator = (HtmlNodeNavigator)it.Current;
var currentNode = nodeNavigator.CurrentNode;

if (it.MoveNext()) // >1
throw new InvalidOperationException("Sequence contains more than one element");

return currentNode;
}

public IHtmlBaseNode SelectSingleNodeOrDefault(string xpath)
{
var it = CreateXPathNodeIterator(xpath);

if (!it.MoveNext()) // 0
return null;

var nodeNavigator = (HtmlNodeNavigator)it.Current;
var currentNode = nodeNavigator.CurrentNode;

if (it.MoveNext()) // >1
throw new InvalidOperationException("Sequence contains more than one element");

return currentNode;
}

public IHtmlBaseNode SelectFirstNode(string xpath)
{
var it = CreateXPathNodeIterator(xpath);

if (!it.MoveNext()) // 0
throw new InvalidOperationException("Sequence contains no elements");

var nodeNavigator = (HtmlNodeNavigator)it.Current;
return nodeNavigator.CurrentNode;
}

public IHtmlBaseNode SelectFirstNodeOrDefault(string xpath)
{
var it = CreateXPathNodeIterator(xpath);

if (!it.MoveNext()) // 0
return null;

var nodeNavigator = (HtmlNodeNavigator)it.Current;
return nodeNavigator.CurrentNode;
}

private XPathNodeIterator CreateXPathNodeIterator(string xpath)
{
if (xpath == null)
{
throw new ArgumentNullException("xpath");
}

var nav = new HtmlNodeNavigator(_ownerdocument, this);
return nav.Select(xpath);
}

Обратите внимание на то, что методы SelectNodes, SelectSingleNode, SelectSingleNodeOrDefault, SelectFirstNode и SelectFirstNodeOrDefault возвращают объекты типа IHtmlBaseNode. Однако зачастую после выполнения запроса приходится приводить IHtmlBaseNode к HtmlNode (тег), AttributeNode (атрибут) или HtmlTextNode (текст). Давайте сделаем так, чтобы можно было задавать тип возвращаемой ноды/нод. Для примера возьмем SelectSingleNode, сделаем его generic и создадим ряд методов-оберток:

public T SelectSingleNode(string xpath) where T: IHtmlBaseNode
{
var it = CreateXPathNodeIterator(xpath);

if (!it.MoveNext()) // 0
throw new InvalidOperationException("Sequence contains no elements");

var nodeNavigator = (HtmlNodeNavigator) it.Current;
var currentNode = (T)nodeNavigator.CurrentNode;

if (it.MoveNext()) // >1
throw new InvalidOperationException("Sequence contains more than one element");

return currentNode;
}

public IHtmlBaseNode SelectSingleNode(string xpath)
{
return SelectSingleNode(xpath);
}

public HtmlNode SelectSingleHtmlNode(string xpath)
{
return SelectSingleNode(xpath);
}

public HtmlAttribute SelectSingleAttributeNode(string xpath)
{
return SelectSingleNode(xpath);
}

public HtmlTextNode SelectSingleTextNode(string xpath)
{
return SelectSingleNode(xpath);
}

Аналогичный рефакторинг необходимо провести и для оставшихся четырех методов.

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

8 комментариев:

Анонимный комментирует...

Подскажите, в чем суть перехода на второй эксперементальный HAP? И как будет выглядеть конструкция, которая раньше выглядела примерно так:
HtmlNode _HN = doc.DocumentNode.SelectSingleNode("//td[h1]");
foreach (HtmlNode hn in _HN.SelectNodes("table[1]/tr[2]//th"))
т.е. как применить методы XPath к IHtmlBaseNode для получения второго уровня группировки? Только через LINQ?

Анонимный комментирует...

Я невнимательно прочел Ваш пост, все ответы в нем есть, спасибо.

Idsa комментирует...

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

Анонимный комментирует...

Я уже заметил что во 2м экспериментальном билде это не реализовано. Пока выкручиваюсь так: HtmlNode hn = (HtmlNode)doc.DocumentNode.SelectSingleNode("//td[h1]");
Но душа болит что что-то не так ))

Idsa комментирует...

Так скачайте исходники экспериментальной бранчи, наложите описанные в посте изменения - и будет вам душевное спокойствие :)

Анонимный комментирует...

Фраза "Анонимный комментирует..." - подбор слов как-то на нехорошие мысли наводит, может изменить немного? ))

Idsa комментирует...

Ну так авторизуйтесь :)

Анонимный комментирует...

не совсем понял что такое версия 2 из бранчи, и где таки ее взять ?