Как распарсить HTML в .NET?

1512
10 февраля 2017, 02:08

Необходимо извлечь все URL из атрибутов href тегов a в HTML странице. Я попробовал воспользоваться регулярными выражениями:

Uri uri = new Uri("http://google.com/search?q=test");
Regex reHref = new Regex(@"<a[^>]+href=""([^""]+)""[^>]+>");
string html = new WebClient().DownloadString(uri);
foreach (Match match in reHref.Matches(html))
    Console.WriteLine(match.Groups[1].ToString());

Но возникает множество потенциальных проблем:

  • Как отфильтровать только специфические ссылки, например, по CSS классу?
  • Что будет, если кавычки у атрибута другие?
  • Что будет, если вокруг знака равенства пробелы?
  • Что будет, если кусок страницы закомментирован?
  • Что будет, если попадётся кусок JavaScript?
  • И так далее.

Регулярное выражение очень быстро становится монструозным и нечитаемыми, а проблемных мест обнаруживается всё больше и больше.

Что делать?

Answer 1

Регулярные выражения предназначены для обработки относительно простых текстов, которые задаются регулярными языками. Регулярные выражения со времени своего появления сильно усложнились, особенно в Perl, реализация регулярных выражений в котором является вдохновением для остальных языков и библиотек, но регулярные выражения всё ещё плохо приспособлены (и вряд ли когда-либо будут) для обработки сложных языков типа HTML. Сложность обработки HTML заключается ещё и в очень сложных правилах обработки невалидного кода, которые достались по наследству от первых реализаций времён рождения Интернета, когда никаких стандартов не было и в помине, а каждый производитель браузеров нагромождал уникальные и неповторимые возможности.

Итак, в общем случае регулярные выражения — не лучший кандидат для обработки HTML. Обычно разумнее использовать специализированные парсеры HTML.

CsQuery

Лицензия: MIT

Один из современных парсеров HTML для .NET. В качестве основы взят парсер validator.nu для Java, который в свою очередь является портом парсера из движка Gecko (Firefox). Это гарантирует, что парсер будет обрабатывать код точно так же, как современные браузеры.

API черпает вдохновение у jQuery, для выбора элементов используется язык селекторов CSS. Названия методов скопированы практически один-в-один, то есть для программистов, знакомых с jQuery, изучение будет простым.

Обладает высокой производительностью. На порядки превосходит HtmlAgilityPack+Fizzler по скорости на сложных запросах.

CQ cq = CQ.Create(html);
foreach (IDomObject obj in cq.Find("a"))
    Console.WriteLine(obj.GetAttribute("href"));

Если требуется более сложный запрос, то код практически не усложняется:

CQ cq = CQ.Create(html);
foreach (IDomObject obj in cq.Find("h3.r a"))
    Console.WriteLine(obj.GetAttribute("href"));
HtmlAgilityPack

Лицензия: Ms-PL

Самый старый, и потому самый популярный парсер для .NET. Однако возраст не означает качество, например, уже пять лет (!!!) висит незакрытым критический баг Incorrect parsing of HTML4 optional end tags, который приводит к некорректной обработке тегов HTML, закрывающие теги для которых опциональны. В API присутствуют странности, например, если ничего не найдено, возвращается null, а не пустая коллекция.

Для выбора элементов используется язык XPath, а не селекторы CSS. На простых запросах код получается более-менее удобочитаемый:

HtmlDocument hap = new HtmlDocument();
hap.LoadHtml(html);
HtmlNodeCollection nodes = hap.DocumentNode.SelectNodes("//a");
if (nodes != null)
    foreach (HtmlNode node in nodes)
        Console.WriteLine(node.GetAttributeValue("href", null));

Однако если нужны сложные запросы, то XPath оказывается не очень приспособленным для имитации CSS селекторов:

HtmlDocument hap = new HtmlDocument();
hap.LoadHtml(html);
HtmlNodeCollection nodes = hap.DocumentNode.SelectNodes(
    "//h3[contains(concat(' ', @class, ' '), ' r ')]/a");
if (nodes != null)
    foreach (HtmlNode node in nodes)
        Console.WriteLine(node.GetAttributeValue("href", null));
Fizzler

Лицензия: LGPL

Надстройка к HtmlAgilityPack, позволяющая использовать селекторы CSS.

HtmlDocument hap = new HtmlDocument();
hap.LoadHtml(html);
foreach (HtmlNode node in hap.DocumentNode.QuerySelectorAll("h3.r a"))
    Console.WriteLine(node.GetAttributeValue("href", null));
AngleSharp

Лицензия: BSD (3-clause)

Новый игрок на поле парсеров. В отличие от CsQuery, написан с нуля вручную на C#. Также включает парсеры других языков.

API построен на базе официальной спецификации по JavaScript HTML DOM. В некоторых местах есть странности, непривычные для разработчиков на .NET (например, при обращении к неверному индексу в коллекции будет возвращён null, а не выброшено исключение; есть свой отдельный класс Url; пространства имён очень гранулярные, даже базовое использование библиотеки требует три using и т. п.), но в целом ничего критичного.

Из других странностей — библиотека тащит за собой Microsoft BCL Portability Pack. Поэтому, когда подключите AngleSharp через NuGet, не удивляйтесь, если обнаружите подключенными три дополнительных пакета: Microsoft.Bcl, Microsoft.Bcl.Build, Microsoft.Bcl.Async.

Обработка HTML простая:

IHtmlDocument angle = new HtmlParser(html).Parse();
foreach (IElement element in angle.QuerySelectorAll("a"))
    Console.WriteLine(element.GetAttribute("href"));

Она не усложняется, и если нужна более сложная логика:

IHtmlDocument angle = new HtmlParser(html).Parse();
foreach (IElement element in angle.QuerySelectorAll("h3.r a"))
    Console.WriteLine(element.GetAttribute("href"));
Regex

Страшные и ужасные регулярные выражения. Применять их нежелательно, но иногда возникает необходимость, так как парсеры, которые строят DOM, заметно прожорливее, чем Regex: они потребляют больше и процессорного времени, и памяти.

Если дошло до регулярных выражений, то нужно понимать, что вы не сможете построить на них универсальное и абсолютно надёжное решение. Однако если вы хотите парсить конкретный сайт, то эта проблема может быть не так критична.

Ради всего святого, не надо превращать регулярные выражения в нечитаемое месиво. Вы не пишете код на C# в одну строчку с однобуквенными именами переменных, так и регулярные выражения не нужно портить. Движок регулярных выражений в .NET достаточно мощный, чтобы можно было писать качественный код.

Например, вот немного доработанный код для извлечения ссылок из вопроса:

Regex reHref = new Regex(@"(?inx)
    <a \s [^>]*
        href \s* = \s*
            (?<q> ['""] )
                (?<url> [^""]+ )
            \k<q>
    [^>]* >");
foreach (Match match in reHref.Matches(html))
    Console.WriteLine(match.Groups["url"].ToString());
Answer 2

Используйте библиотеку CefSharp для решения подобных задач.

Почему следует применять именно такой подход?

  • У вас намного упрощается процесс разработки за счёт того, что вместо написания XPath, условий и/или циков в C# вы просто в консоли браузера (желательно основанного на Chromuium) просто разрабатываете всё что вам нужно, затем когда уже написан небольшой костяк из класса (покажу его ниже), вы просто вставляете JavaScript-код, который вам нужен.
  • Надёжность. Вы не пытаетесь парсить HTML и не изобретаете велосипед, что является почти всегда очень плохой идеей. Проект основан на Chromium, поэтому вам не приходится доверять какому-то новому/незнакомому продукту. Активно поддерживается для синхронизации с новой версией.

Для Javascript-обращений для простоты и демонстрации используется jQuery, предполагая, что на целевом сайте он тоже есть. Но это может быть также чистый JavaScript либо другая библиотека при условии, что эта библиотека используется на сайте.

Если вы проскроллите вниз, то заметите, что помимо написания небольшой прослойки кода и инициалиации, решение занимает одну-две строки:

string[] urls = await wrapper.GetResultAfterPageLoad("https://yandex.ru",
    async () => await wrapper.EvaluateJavascript<string[]>(
    "$('a[href]').map((index, element) => $(element).prop('href')).toArray()"));

Что это такое?

Это управляемая оболочка над CEF (Chromium Embedded Framework). То есть Вы получаете мощь Chromium, которой управляете программно.

Почему именно CEF/CefSharp?

  • Не стоит заморачиваться парсингом страниц (а это сложная и неблагодарная задача, которую крайне не рекомендую делать).
  • Можно работать с уже загруженной страницей (после выполнения скриптов).
  • Есть возможность выполнять произвольный JavaScript с последними возможностями.
  • Даёт возможность вызывать AJAX с помощью JavaScript, а затем при успехе (success), дёргать события в C#-коде с результатом AJAX. Подробно и с примером рассмотрел здесь.

Разновидности CefSharp

  • CefSharp.WinForms
  • CefSharp.Wpf
  • CefSharp.OffScreen

Первые две используются если вам надо дать пользователям элемент управления "Браузер". Концептуально похоже на WebBrowser в Windows Forms, который является оболочкой для управления IE, а не Chromium, как в нашем случае.

Поэтому мы будем использовать CefSharp.OffScreen (закадровую) разновидность.

Написание кода

Допустим у нас консольное приложение, но это уже зависит от Вас.

Устанавливаем Nuget-пакет CefSharp.OffScreen 51-ой версии:
Install-Package CefSharp.OffScreen -Version 51.0.0

Дело в том, что C# всё массивы маппает к List<object>, результат JavaScript обёрнут в object, в котором уже содержатся List<object>, string, bool, int в зависимости от результата. Для того чтобы сделать результаты строго типизированными, создаём небольшой ConvertHelper:

public static class ConvertHelper
{
    public static T[] GetArrayFromObjectList<T>(object obj)
    {
        return ((IEnumerable<object>)obj)
            .Cast<T>()
            .ToArray();
    }
    public static List<T> GetListFromObjectList<T>(object obj)
    {
        return ((IEnumerable<object>)obj)
            .Cast<T>()
            .ToList();
    }
    public static T ToTypedVariable<T>(object obj)
    {
        if (obj == null)
        {
            dynamic dynamicResult = null;
            return dynamicResult;
        }
        Type type = typeof(T);
        if (type.IsArray)
        {
            dynamic dynamicResult = typeof(ConvertHelper).GetMethod(nameof(GetArrayFromObjectList))
                .MakeGenericMethod(type.GetElementType())
                .Invoke(null, new[] { obj });
            return dynamicResult;
        }
        if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>))
        {
            dynamic dynamicResult = typeof(ConvertHelper).GetMethod(nameof(GetListFromObjectList))
                .MakeGenericMethod(type.GetGenericArguments().Single())
                .Invoke(null, new[] { obj });
            return dynamicResult;
        }
        return (T)obj;
    }
}

Создаём класс CefSharpWrapper:

public sealed class CefSharpWrapper
{
    private ChromiumWebBrowser _browser;
    public void InitializeBrowser()
    {
        CefSettings settings = new CefSettings();
        // Disable GPU in WPF and Offscreen until GPU issues has been resolved
        settings.CefCommandLineArgs.Add("disable-gpu", "1");
        //Perform dependency check to make sure all relevant resources are in our output directory.
        Cef.Initialize(settings, shutdownOnProcessExit: true, performDependencyCheck: true);
        _browser = new ChromiumWebBrowser();
        // wait till browser initialised
        AutoResetEvent waitHandle = new AutoResetEvent(false);
        EventHandler onBrowserInitialized = null;
        onBrowserInitialized = (sender, e) =>
        {
            _browser.BrowserInitialized -= onBrowserInitialized;
            waitHandle.Set();
        };
        _browser.BrowserInitialized += onBrowserInitialized;
        waitHandle.WaitOne();
    }
    public void ShutdownBrowser()
    {
        // Clean up Chromium objects.  You need to call this in your application otherwise
        // you will get a crash when closing.
        Cef.Shutdown();
    }
    public Task<T> GetResultAfterPageLoad<T>(string pageUrl, Func<Task<T>> onLoadCallback)
    {
        TaskCompletionSource<T> tcs = new TaskCompletionSource<T>();
        EventHandler<LoadingStateChangedEventArgs> onPageLoaded = null;
        T t = default(T);
        // An event that is fired when the first page is finished loading.
        // This returns to us from another thread.
        onPageLoaded = async (sender, e) =>
        {
            // Check to see if loading is complete - this event is called twice, one when loading starts
            // second time when it's finished
            // (rather than an iframe within the main frame).
            if (!e.IsLoading)
            {
                // Remove the load event handler, because we only want one snapshot of the initial page.
                _browser.LoadingStateChanged -= onPageLoaded;
                t = await onLoadCallback();
                tcs.SetResult(t);
            }
        };
        _browser.LoadingStateChanged += onPageLoaded;
        _browser.Load(pageUrl);
        return tcs.Task;
    }
    public async Task EvaluateJavascript<T>(string script)
    {
        JavascriptResponse javascriptResponse = await _browser.EvaluateScriptAsync(script);
        if (!javascriptResponse.Success)
        {
            throw new ScriptException(javascriptResponse.Message);
        }
    }
    public async Task<T> EvaluateJavascript<T>(string script)
    {
        JavascriptResponse javascriptResponse = await _browser.EvaluateScriptAsync(script);
        if (javascriptResponse.Success)
        {
            object scriptResult = javascriptResponse.Result;
            return ConvertHelper.ToTypedVariable<T>(scriptResult);
        }
        throw new ScriptException(javascriptResponse.Message);
    }
}

Далее вызываем наш класс CefSharpWrapper из метода Main.

public class Program
{
    private static void Main()
    {
        MainAsync().Wait();
    }
    private static async Task MainAsync()
    {
        CefSharpWrapper wrapper = new CefSharpWrapper();
        wrapper.InitializeBrowser();
        string[] urls = await wrapper.GetResultAfterPageLoad("https://yandex.ru", async () =>
            await wrapper.EvaluateJavascript<string[]>("$('a[href]').map((index, element) => $(element).prop('href')).toArray()"));
        wrapper.ShutdownBrowser();
    }
}

Также: в данной библиотеке есть особенность, что пустой JavaScript-массив приводится к null. Поэтому, возможно, есть смысл добавить в ConvertHelper соотвествующий код, либо в вызывающем коде писать что-то вроде

if (urls == null) urls = new string[0]

Также установите x64 или x86 в качестве платформы. Платформа Any CPU данной библиотекой не поддерживается.

Answer 3

У меня все замечательно получается при помощи XElement Попробуйте :)

var htmlDom = XElement.Parse("[Код HTML]");

Как подсказали в комментариях, это будет работать если нужная нам страница является валидным XHTML документом.

READ ALSO
как быстрее обращаться к данным в строке DataTable

как быстрее обращаться к данным в строке DataTable

Во время прорисовки компонента нужно использовать некоторые значения текущей строки из набора данных DataTable (допустим, построчно)Как быстрее...

359
С#. Получение всех дней указанного месяца

С#. Получение всех дней указанного месяца

ЗдравствуйтеМне нужно получать все дни указанного месяца в виде коллекции дней, у каждого дня мне нужно знать его число и название

354
Как VS 2015 исправить глюк с designer.cs

Как VS 2015 исправить глюк с designer.cs

Как заставить студию перегенерировать designercs файл в WebApplication?

425
Парсинг html таблицы в c# без сторонних библиотек

Парсинг html таблицы в c# без сторонних библиотек

Добрый день(утро, вечер, ночь)! У меня возникла одна небольшая проблема: есть страница с таблицей (конкретно эта) и я ее должен превратить в datatable,...

504